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
+9 -12
View File
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import '../model/device.dart';
import '../model/device_event.dart';
import '../utils/oott_api.dart';
import '../widgets/filter_selector.dart';
enum _TimeRange {
today('Today'),
@@ -128,18 +129,14 @@ class _DeviceEventHistoryState extends State<DeviceEventHistory> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SegmentedButton<_TimeRange>(
segments: _TimeRange.values
.map((r) => ButtonSegment(value: r, label: Text(r.label)))
.toList(),
selected: {_selectedRange},
onSelectionChanged: (selection) {
setState(() => _selectedRange = selection.first);
_loadEvents();
},
),
FilterSelector<_TimeRange>(
values: _TimeRange.values,
selected: _selectedRange,
labelOf: (r) => r.label,
onSelected: (range) {
setState(() => _selectedRange = range);
_loadEvents();
},
),
const SizedBox(height: 16),
if (events.isEmpty)
+14 -15
View File
@@ -2,12 +2,12 @@ import 'package:flutter/material.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
/// [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.
/// On wide (tablet/desktop) layouts the options are laid out as a
/// [SegmentedButton] of pills. On narrow (phone) layouts, where a full row of
/// pills 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,
@@ -32,27 +32,26 @@ class FilterSelector<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.xs,
),
child: Wrap(
spacing: Insets.sm,
children: values
child: SegmentedButton<T>(
showSelectedIcon: false,
segments: values
.map(
(value) => ChoiceChip(
label: Text(labelOf(value)),
selected: selected == value,
onSelected: (_) => onSelected(value),
),
(value) =>
ButtonSegment<T>(value: value, label: Text(labelOf(value))),
)
.toList(),
selected: {selected},
onSelectionChanged: (selection) => onSelected(selection.first),
),
);
}
+10 -5
View File
@@ -23,7 +23,9 @@ Widget _harness({
}
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;
await tester.pumpWidget(
_harness(
@@ -33,9 +35,12 @@ void main() {
),
);
// All three options are visible as chips at once.
expect(find.byType(ChoiceChip), findsNWidgets(3));
// All three options are visible as segments at once.
expect(find.byType(SegmentedButton<String>), findsOneWidget);
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'));
expect(picked, 'Old');
@@ -51,8 +56,8 @@ void main() {
),
);
// Collapsed: no chips, a single button showing the current selection.
expect(find.byType(ChoiceChip), findsNothing);
// Collapsed: no segmented pills, a single button showing the selection.
expect(find.byType(SegmentedButton<String>), findsNothing);
expect(find.byType(PopupMenuButton<String>), findsOneWidget);
expect(find.text('New'), findsOneWidget);
expect(find.text('Old'), findsNothing);