diff --git a/TODO.md b/TODO.md index 3ec74c1..aa487bb 100644 --- a/TODO.md +++ b/TODO.md @@ -26,7 +26,7 @@ - [x] Mobile - Implement pull to refresh on the notifications and devices list - [x] Mobile - reduce lists length (# of items) so they fit on a phone in one screen (use iPhone latest gen and Google phone latest gen) -- [ ] Mobile - In the devices list the filters and sort buttons overlap (dont fit in the screen) +- [x] Mobile - In the devices list the filters and sort buttons overlap (dont fit in the screen) - [ ] When changing pages (either list) the items should change to placeholders while loading - [ ] The notifications list should not refresh coldly every time. It should add/remove notifications with an animation as if a stack - [x] Make gruvbox the default theme diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index 7badb59..de106d3 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -11,6 +11,7 @@ import '../theme/dimens.dart'; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; import '../widgets/empty_state.dart'; +import '../widgets/filter_selector.dart'; import '../widgets/pagination_bar.dart'; import '../widgets/skeleton.dart'; import 'device_list_filter.dart'; @@ -240,27 +241,14 @@ class _DeviceListState extends State with RouteAware { Row( children: [ Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: Insets.sm, - vertical: Insets.xs, - ), - child: Wrap( - spacing: Insets.sm, - children: DeviceFilter.values - .map( - (f) => ChoiceChip( - label: Text(f.label), - selected: _filter == f, - onSelected: (_) { - setState(() => _filter = f); - _fetchPage(0); - }, - ), - ) - .toList(), - ), + child: FilterSelector( + values: DeviceFilter.values, + selected: _filter, + labelOf: (f) => f.label, + onSelected: (f) { + setState(() => _filter = f); + _fetchPage(0); + }, ), ), if (!isWide) diff --git a/frontend/lib/home/notifications_list.dart b/frontend/lib/home/notifications_list.dart index a4f03e9..54b83b5 100644 --- a/frontend/lib/home/notifications_list.dart +++ b/frontend/lib/home/notifications_list.dart @@ -10,6 +10,7 @@ import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; import '../utils/ui_snackbars.dart'; import '../widgets/empty_state.dart'; +import '../widgets/filter_selector.dart'; import '../widgets/pagination_bar.dart'; import '../widgets/skeleton.dart'; import 'notification_card.dart'; @@ -266,27 +267,14 @@ class _NotificationsListState extends State ), ], ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: Insets.sm, - vertical: Insets.xs, - ), - child: Wrap( - spacing: Insets.sm, - children: _NotificationFilter.values - .map( - (f) => ChoiceChip( - label: Text(f.label), - selected: _filter == f, - onSelected: (_) { - setState(() => _filter = f); - _fetchPage(0); - }, - ), - ) - .toList(), - ), + FilterSelector<_NotificationFilter>( + values: _NotificationFilter.values, + selected: _filter, + labelOf: (f) => f.label, + onSelected: (f) { + setState(() => _filter = f); + _fetchPage(0); + }, ), ], ); diff --git a/frontend/lib/widgets/filter_selector.dart b/frontend/lib/widgets/filter_selector.dart new file mode 100644 index 0000000..a593c22 --- /dev/null +++ b/frontend/lib/widgets/filter_selector.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../theme/dimens.dart'; + +/// A single-select filter control that adapts to the available width. +/// +/// On wide (tablet/desktop) layouts the options are laid out as a row of +/// [ChoiceChip]s. On narrow (phone) layouts, where a full row of chips competes +/// for horizontal space with neighbouring actions, it collapses to a compact +/// dropdown button showing the current selection. +class FilterSelector extends StatelessWidget { + const FilterSelector({ + super.key, + required this.values, + required this.selected, + required this.labelOf, + required this.onSelected, + }); + + /// The selectable options, in display order. + final List values; + + /// The currently selected option. + final T selected; + + /// Builds the human-readable label for an option. + final String Function(T value) labelOf; + + /// Called with the option the user picked. + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final isWide = MediaQuery.sizeOf(context).width >= Breakpoints.medium; + return isWide ? _buildChips(context) : _buildDropdown(context); + } + + Widget _buildChips(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: Insets.sm, + vertical: Insets.xs, + ), + child: Wrap( + spacing: Insets.sm, + children: values + .map( + (value) => ChoiceChip( + label: Text(labelOf(value)), + selected: selected == value, + onSelected: (_) => onSelected(value), + ), + ) + .toList(), + ), + ); + } + + Widget _buildDropdown(BuildContext context) { + final theme = Theme.of(context); + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Insets.sm, + vertical: Insets.xs, + ), + child: PopupMenuButton( + initialValue: selected, + tooltip: 'Change filter', + onSelected: onSelected, + itemBuilder: (context) => values + .map( + (value) => PopupMenuItem( + value: value, + child: Text(labelOf(value)), + ), + ) + .toList(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Insets.md, + vertical: Insets.sm, + ), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outline), + borderRadius: BorderRadius.circular(Insets.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(labelOf(selected), style: theme.textTheme.labelLarge), + const SizedBox(width: Insets.xs), + Icon( + Icons.arrow_drop_down, + size: 20, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/test/widget/filter_selector_test.dart b/frontend/test/widget/filter_selector_test.dart new file mode 100644 index 0000000..9090475 --- /dev/null +++ b/frontend/test/widget/filter_selector_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frontend/widgets/filter_selector.dart'; + +Widget _harness({ + required Size size, + required String selected, + required ValueChanged onSelected, +}) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: size), + child: Scaffold( + body: FilterSelector( + values: const ['New', 'Old', 'All'], + selected: selected, + labelOf: (v) => v, + onSelected: onSelected, + ), + ), + ), + ); +} + +void main() { + testWidgets('wide layout shows chips and reports selection', (tester) async { + String? picked; + await tester.pumpWidget( + _harness( + size: const Size(900, 600), + selected: 'New', + onSelected: (v) => picked = v, + ), + ); + + // All three options are visible as chips at once. + expect(find.byType(ChoiceChip), findsNWidgets(3)); + expect(find.byType(PopupMenuButton), findsNothing); + + await tester.tap(find.text('Old')); + expect(picked, 'Old'); + }); + + testWidgets('narrow layout collapses to a dropdown button', (tester) async { + String? picked; + await tester.pumpWidget( + _harness( + size: const Size(400, 600), + selected: 'New', + onSelected: (v) => picked = v, + ), + ); + + // Collapsed: no chips, a single button showing the current selection. + expect(find.byType(ChoiceChip), findsNothing); + expect(find.byType(PopupMenuButton), findsOneWidget); + expect(find.text('New'), findsOneWidget); + expect(find.text('Old'), findsNothing); + + // Opening the menu reveals the options; picking one reports it. + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + expect(find.text('Old'), findsOneWidget); + + await tester.tap(find.text('Old')); + await tester.pumpAndSettle(); + expect(picked, 'Old'); + }); +}