diff --git a/app/lib/widgets/meal_schedule_editor.dart b/app/lib/widgets/meal_schedule_editor.dart index a6bfd97..cde3008 100644 --- a/app/lib/widgets/meal_schedule_editor.dart +++ b/app/lib/widgets/meal_schedule_editor.dart @@ -9,8 +9,8 @@ import 'package:diet_guard_app/widgets/meal_time_dropdown.dart'; import 'package:flutter/material.dart'; /// Latest minute the *grid* offers for the first meal: 23:30, leaving one -/// grid step for a grid-aligned last meal. "Custom…" can go later, up to the -/// 23:44 that [MealSchedule.normalized] allows. +/// grid step for a grid-aligned last meal. The clock button can go later, up +/// to the 23:44 that [MealSchedule.normalized] allows. const int _firstGridMax = kMinutesPerDay - 2 * kSlotGridMinutes; /// Latest first-meal minute [MealSchedule.normalized] keeps: one grid step diff --git a/app/lib/widgets/meal_time_dropdown.dart b/app/lib/widgets/meal_time_dropdown.dart index f89e0bb..0085315 100644 --- a/app/lib/widgets/meal_time_dropdown.dart +++ b/app/lib/widgets/meal_time_dropdown.dart @@ -1,4 +1,4 @@ -/// A minute-of-day picker: a 15-minute dropdown plus a "Custom…" escape. +/// A minute-of-day picker: a 15-minute dropdown plus an exact-time button. library; import 'dart:async'; @@ -8,15 +8,15 @@ import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; -/// The dropdown value standing for "Custom…". Negative, so it can never -/// collide with a real minute of day (`0..1439`). -const int _customValue = -1; - /// Picks a minute of day, offering the 15-minute grid within -/// [minMinute]..[maxMinute] and a "Custom…" item that opens a time picker -/// for any minute in that range. +/// [minMinute]..[maxMinute], with a clock button beside it that opens a time +/// picker for any minute in that range. /// -/// The grid is what most people want and keeps the list short; "Custom…" +/// A separate button rather than a "Custom…" menu item: the menu opens +/// centred on the current value, so an item at either end of ~95 entries was +/// always a long scroll away. +/// +/// The grid is what most people want and keeps the list short; the button /// exists because an endpoint may legitimately sit off the grid (07:23 to /// match a train), and because the PC can set such a value and sync it here. /// That second case is why the current [value] is always offered as an item @@ -44,12 +44,12 @@ class MealTimeDropdown extends StatelessWidget { /// Earliest minute offered (inclusive). final int minMinute; - /// Latest minute offered (inclusive), through "Custom…" if not the grid. + /// Latest minute offered (inclusive), via the clock button if not the grid. final int maxMinute; /// Latest grid mark listed, when it must stop short of [maxMinute] (the /// first meal's grid ends at 23:30 so a grid-aligned last meal still fits, - /// while "Custom…" may go to 23:44). Defaults to [maxMinute]. + /// while the clock button may go to 23:44). Defaults to [maxMinute]. final int? gridMaxMinute; /// Called with the chosen minute, already clamped into @@ -77,7 +77,7 @@ class MealTimeDropdown extends StatelessWidget { ? minMinute : (minute > maxMinute ? maxMinute : minute); - Future _pickCustom(BuildContext context) async { + Future _pickExact(BuildContext context) async { final picked = await showTimePicker( context: context, initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60), @@ -99,29 +99,36 @@ class MealTimeDropdown extends StatelessWidget { @override Widget build(BuildContext context) => ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.field), - child: InputDecorator( - decoration: InputDecoration(labelText: label), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isDense: true, - isExpanded: true, - // "Custom…" first: at the end it sat ~95 grid items down. - items: [ - const DropdownMenuItem(value: _customValue, child: Text('Custom…')), - for (final minute in options) - DropdownMenuItem(value: minute, child: Text(slotLabel(minute))), - ], - onChanged: (minute) { - if (minute == null) return; - if (minute == _customValue) { - unawaited(_pickCustom(context)); - } else { - onChanged(minute); - } - }, + child: Row( + spacing: AppSpacing.xs, + children: [ + Expanded( + child: InputDecorator( + decoration: InputDecoration(labelText: label), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + items: [ + for (final minute in options) + DropdownMenuItem( + value: minute, + child: Text(slotLabel(minute)), + ), + ], + onChanged: (minute) => + minute == null ? null : onChanged(minute), + ), + ), + ), ), - ), + IconButton( + icon: const Icon(Icons.schedule), + tooltip: 'Type or pick an exact time', + onPressed: () => unawaited(_pickExact(context)), + ), + ], ), ); } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d522a96..8f744de 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.109+1 +version: 1.0.110+1 environment: sdk: ^3.12.2 diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index 515c4b2..27a86e6 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -85,21 +85,27 @@ void main() { expect(find.text('Meals per day'), findsOneWidget); }); }); - testWidgets('picking a quarter-hour first meal saves the schedule', ( - tester, - ) async { + testWidgets('an exact 07:23 first meal saves the schedule', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await tester.tap(find.widgetWithText(InputDecorator, 'First meal')); + await tester.tap(find.byTooltip('Type or pick an exact time').first); await settle(tester); - await tester.tap(find.text('07:45').last); + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await settle(tester); + final fields = find.descendant( + of: find.byType(TimePickerDialog), + matching: find.byType(TextField), + ); + await tester.enterText(fields.at(0), '07'); + await tester.enterText(fields.at(1), '23'); + await tester.tap(find.text('OK')); await settle(tester); // Through the real _applySchedule: normalised, recorded, persisted. - expect(MealScheduleService.current.firstMinute, 465); - expect(find.text('07:45 · 11:45 · 16:00 · 20:00'), findsOneWidget); + expect(MealScheduleService.current.firstMinute, 443); + expect(find.text('07:23 · 11:30 · 15:45 · 20:00'), findsOneWidget); }); }); testWidgets('reflects a five-meal schedule as the user\'s example', ( diff --git a/app/test/widgets/meal_schedule_editor_test.dart b/app/test/widgets/meal_schedule_editor_test.dart index 126d2a2..f980ff8 100644 --- a/app/test/widgets/meal_schedule_editor_test.dart +++ b/app/test/widgets/meal_schedule_editor_test.dart @@ -55,17 +55,22 @@ void main() { await tester.pumpAndSettle(); } - /// Picks "Custom…" from the open menu, typing [hhmm] in the time picker's + /// Taps [label]'s clock button and types [hh]:[mm] in the time picker's /// keyboard mode. - Future pickCustom(WidgetTester tester, String hh, String mm) async { - // The menu opens centred on the current value; "Custom…" is the first - // item, so it is reached by scrolling up. - await tester.scrollUntilVisible( - find.text('Custom…'), - -300, - scrollable: find.byType(Scrollable).last, + Future pickExact( + WidgetTester tester, + String label, + String hh, + String mm, + ) async { + await tester.tap( + find.descendant( + of: find.byWidgetPredicate( + (w) => w is MealTimeDropdown && w.label == label, + ), + matching: find.byTooltip('Type or pick an exact time'), + ), ); - await tester.tap(find.text('Custom…').last); await tester.pumpAndSettle(); expect(find.byType(TimePickerDialog), findsOneWidget); await tester.tap(find.byIcon(Icons.keyboard_outlined)); @@ -107,38 +112,40 @@ void main() { expect(find.text('07:45 · 10:45 · 14:00 · 17:00 · 20:00'), findsOne); }); - testWidgets('Custom… opens the time picker and applies 07:23', ( + testWidgets('the clock button opens the time picker and applies 07:23', ( tester, ) async { await pump(tester, _fiveMeals); - await openDropdown(tester, 'First meal'); - await pickCustom(tester, '07', '23'); + await pickExact(tester, 'First meal', '07', '23'); expect(edits.last.firstMinute, 443); expect(edits.last.lastMinute, 1200); expect(find.text('07:23'), findsWidgets); }); - testWidgets('Custom… is the first item, above 00:00', (tester) async { + testWidgets('no "Custom…" item remains in either menu', (tester) async { await pump(tester, _fiveMeals); + for (final dropdown in tester.widgetList>( + find.byType(DropdownButton), + )) { + final values = [for (final item in dropdown.items!) item.value!]; + expect(values.every((v) => v >= 0), isTrue, reason: 'no sentinel'); + } await openDropdown(tester, 'First meal'); await tester.scrollUntilVisible( find.text('00:00'), -300, scrollable: find.byType(Scrollable).last, ); - - final custom = tester.getTopLeft(find.text('Custom…').last).dy; - expect(custom, lessThan(tester.getTopLeft(find.text('00:00').last).dy)); + expect(find.text('Custom…'), findsNothing); }); - testWidgets('a custom last meal before the first is clamped', (tester) async { + testWidgets('an exact last meal before the first is clamped', (tester) async { await pump(tester, _fiveMeals); - await openDropdown(tester, 'Last meal'); - await pickCustom(tester, '06', '00'); + await pickExact(tester, 'Last meal', '06', '00'); // One grid step after 08:00: the shortest legal window, not a rejection. expect(edits.last.lastMinute, 495);