mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Collapse list filters to a dropdown on phones
On narrow layouts the devices list's filter chips (Not registered / Registered / All) competed for horizontal space with the Sort and Filter icon buttons and overlapped. Introduce a reusable FilterSelector that keeps the chips on wide layouts but collapses to a compact dropdown button on phones, and use it for both the devices and notifications lists for consistency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0a899b7890
commit
f64b4d3559
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
- [x] Mobile - Implement pull to refresh on the notifications and devices list
|
- [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)
|
- [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
|
- [ ] 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
|
- [ ] 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
|
- [x] Make gruvbox the default theme
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../theme/dimens.dart';
|
|||||||
import '../utils/friendly_date_formatter.dart';
|
import '../utils/friendly_date_formatter.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
|
import '../widgets/filter_selector.dart';
|
||||||
import '../widgets/pagination_bar.dart';
|
import '../widgets/pagination_bar.dart';
|
||||||
import '../widgets/skeleton.dart';
|
import '../widgets/skeleton.dart';
|
||||||
import 'device_list_filter.dart';
|
import 'device_list_filter.dart';
|
||||||
@@ -240,27 +241,14 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: FilterSelector<DeviceFilter>(
|
||||||
scrollDirection: Axis.horizontal,
|
values: DeviceFilter.values,
|
||||||
padding: const EdgeInsets.symmetric(
|
selected: _filter,
|
||||||
horizontal: Insets.sm,
|
labelOf: (f) => f.label,
|
||||||
vertical: Insets.xs,
|
onSelected: (f) {
|
||||||
),
|
setState(() => _filter = f);
|
||||||
child: Wrap(
|
_fetchPage(0);
|
||||||
spacing: Insets.sm,
|
},
|
||||||
children: DeviceFilter.values
|
|
||||||
.map(
|
|
||||||
(f) => ChoiceChip(
|
|
||||||
label: Text(f.label),
|
|
||||||
selected: _filter == f,
|
|
||||||
onSelected: (_) {
|
|
||||||
setState(() => _filter = f);
|
|
||||||
_fetchPage(0);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!isWide)
|
if (!isWide)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../utils/friendly_date_formatter.dart';
|
|||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../utils/ui_snackbars.dart';
|
import '../utils/ui_snackbars.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
|
import '../widgets/filter_selector.dart';
|
||||||
import '../widgets/pagination_bar.dart';
|
import '../widgets/pagination_bar.dart';
|
||||||
import '../widgets/skeleton.dart';
|
import '../widgets/skeleton.dart';
|
||||||
import 'notification_card.dart';
|
import 'notification_card.dart';
|
||||||
@@ -266,27 +267,14 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SingleChildScrollView(
|
FilterSelector<_NotificationFilter>(
|
||||||
scrollDirection: Axis.horizontal,
|
values: _NotificationFilter.values,
|
||||||
padding: const EdgeInsets.symmetric(
|
selected: _filter,
|
||||||
horizontal: Insets.sm,
|
labelOf: (f) => f.label,
|
||||||
vertical: Insets.xs,
|
onSelected: (f) {
|
||||||
),
|
setState(() => _filter = f);
|
||||||
child: Wrap(
|
_fetchPage(0);
|
||||||
spacing: Insets.sm,
|
},
|
||||||
children: _NotificationFilter.values
|
|
||||||
.map(
|
|
||||||
(f) => ChoiceChip(
|
|
||||||
label: Text(f.label),
|
|
||||||
selected: _filter == f,
|
|
||||||
onSelected: (_) {
|
|
||||||
setState(() => _filter = f);
|
|
||||||
_fetchPage(0);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<T> 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<T> 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<T> 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<T>(
|
||||||
|
initialValue: selected,
|
||||||
|
tooltip: 'Change filter',
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (context) => values
|
||||||
|
.map(
|
||||||
|
(value) => PopupMenuItem<T>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> onSelected,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: MediaQueryData(size: size),
|
||||||
|
child: Scaffold(
|
||||||
|
body: FilterSelector<String>(
|
||||||
|
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<String>), 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<String>), 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<String>));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Old'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Old'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(picked, 'Old');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user