mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
app settings: exact-time clock button replaces the "Custom…" item
The dropdown menu opens centred on the current value, so a "Custom…" item
at either end of ~95 entries was always a long scroll away. Each meal-time
field now has a clock IconButton ("Type or pick an exact time") opening the
same 24h showTimePicker with the same clamping. The off-grid current value
is still offered as an item.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
This commit is contained in:
parent
b2176f8b69
commit
cb8028bd22
@ -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
|
||||
|
||||
@ -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<void> _pickCustom(BuildContext context) async {
|
||||
Future<void> _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<int>(
|
||||
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<int>(
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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<void> 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<void> 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<DropdownButton<int>>(
|
||||
find.byType(DropdownButton<int>),
|
||||
)) {
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user