Collapse chart time-range pills to a dropdown on phones

The device detail chart's time-range selector (Today / Last week / ...)
rendered as a SegmentedButton that overflowed on narrow phone layouts.
Reuse the responsive FilterSelector widget so the same control is used
for both the list filters and the chart: segmented pills on wide layouts,
a compact dropdown combo box on phones.

To keep the two controls consistent, FilterSelector now renders a
SegmentedButton (instead of ChoiceChips) on wide layouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-05 19:29:38 -04:00
co-authored by Claude Opus 4.8
parent 4bbaa6d3e1
commit b3a4e4cb0b
4 changed files with 34 additions and 41 deletions
+1 -9
View File
@@ -28,15 +28,7 @@
- [ ] Review the whole codebase for dead code, duplication and simplicity - [ ] Review the whole codebase for dead code, duplication and simplicity
- [ ] In the devices list, add an order by type (in the wide version it should be over the icon on the left of the list) - [ ] In the devices list, add an order by type (in the wide version it should be over the icon on the left of the list)
- [ ] How expensive it is to get the total number of pages and implement a go to last page - [ ] How expensive it is to get the total number of pages and implement a go to last page
- [ ] Mobile - Device details - Chart buttons not fully visible, replace with combo only for narrow devices - [x] Mobile - Device details - Chart buttons not fully visible, replace with combo only for narrow devices
- [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 - In the devices list the filters and sort buttons overlap (dont fit in the screen)
- [x] When changing pages (either list) the items should change to placeholders while loading
- [x] 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] Can we add front-end tests?
- [x] Break down oott_api.dart in modules
## Improve engine ## Improve engine
+9 -12
View File
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import '../model/device.dart'; import '../model/device.dart';
import '../model/device_event.dart'; import '../model/device_event.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../widgets/filter_selector.dart';
enum _TimeRange { enum _TimeRange {
today('Today'), today('Today'),
@@ -128,18 +129,14 @@ class _DeviceEventHistoryState extends State<DeviceEventHistory> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SingleChildScrollView( FilterSelector<_TimeRange>(
scrollDirection: Axis.horizontal, values: _TimeRange.values,
child: SegmentedButton<_TimeRange>( selected: _selectedRange,
segments: _TimeRange.values labelOf: (r) => r.label,
.map((r) => ButtonSegment(value: r, label: Text(r.label))) onSelected: (range) {
.toList(), setState(() => _selectedRange = range);
selected: {_selectedRange}, _loadEvents();
onSelectionChanged: (selection) { },
setState(() => _selectedRange = selection.first);
_loadEvents();
},
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (events.isEmpty) if (events.isEmpty)
+14 -15
View File
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import '../theme/dimens.dart'; import '../theme/dimens.dart';
/// A single-select filter control that adapts to the available width. /// A single-select control that adapts to the available width.
/// ///
/// On wide (tablet/desktop) layouts the options are laid out as a row of /// On wide (tablet/desktop) layouts the options are laid out as a
/// [ChoiceChip]s. On narrow (phone) layouts, where a full row of chips competes /// [SegmentedButton] of pills. On narrow (phone) layouts, where a full row of
/// for horizontal space with neighbouring actions, it collapses to a compact /// pills competes for horizontal space with neighbouring actions, it collapses
/// dropdown button showing the current selection. /// to a compact dropdown button showing the current selection.
class FilterSelector<T> extends StatelessWidget { class FilterSelector<T> extends StatelessWidget {
const FilterSelector({ const FilterSelector({
super.key, super.key,
@@ -32,27 +32,26 @@ class FilterSelector<T> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isWide = MediaQuery.sizeOf(context).width >= Breakpoints.medium; final isWide = MediaQuery.sizeOf(context).width >= Breakpoints.medium;
return isWide ? _buildChips(context) : _buildDropdown(context); return isWide ? _buildSegmented(context) : _buildDropdown(context);
} }
Widget _buildChips(BuildContext context) { Widget _buildSegmented(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: Insets.sm, horizontal: Insets.sm,
vertical: Insets.xs, vertical: Insets.xs,
), ),
child: Wrap( child: SegmentedButton<T>(
spacing: Insets.sm, showSelectedIcon: false,
children: values segments: values
.map( .map(
(value) => ChoiceChip( (value) =>
label: Text(labelOf(value)), ButtonSegment<T>(value: value, label: Text(labelOf(value))),
selected: selected == value,
onSelected: (_) => onSelected(value),
),
) )
.toList(), .toList(),
selected: {selected},
onSelectionChanged: (selection) => onSelected(selection.first),
), ),
); );
} }
+10 -5
View File
@@ -23,7 +23,9 @@ Widget _harness({
} }
void main() { void main() {
testWidgets('wide layout shows chips and reports selection', (tester) async { testWidgets('wide layout shows segmented pills and reports selection', (
tester,
) async {
String? picked; String? picked;
await tester.pumpWidget( await tester.pumpWidget(
_harness( _harness(
@@ -33,9 +35,12 @@ void main() {
), ),
); );
// All three options are visible as chips at once. // All three options are visible as segments at once.
expect(find.byType(ChoiceChip), findsNWidgets(3)); expect(find.byType(SegmentedButton<String>), findsOneWidget);
expect(find.byType(PopupMenuButton<String>), findsNothing); expect(find.byType(PopupMenuButton<String>), findsNothing);
expect(find.text('New'), findsOneWidget);
expect(find.text('Old'), findsOneWidget);
expect(find.text('All'), findsOneWidget);
await tester.tap(find.text('Old')); await tester.tap(find.text('Old'));
expect(picked, 'Old'); expect(picked, 'Old');
@@ -51,8 +56,8 @@ void main() {
), ),
); );
// Collapsed: no chips, a single button showing the current selection. // Collapsed: no segmented pills, a single button showing the selection.
expect(find.byType(ChoiceChip), findsNothing); expect(find.byType(SegmentedButton<String>), findsNothing);
expect(find.byType(PopupMenuButton<String>), findsOneWidget); expect(find.byType(PopupMenuButton<String>), findsOneWidget);
expect(find.text('New'), findsOneWidget); expect(find.text('New'), findsOneWidget);
expect(find.text('Old'), findsNothing); expect(find.text('Old'), findsNothing);