mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 16:14:01 +01:00
Make manual workout form text-free
Fields carry a glyph (old label spoken), group headings are dividers, budget and date are numbers, errors an error glyph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b584c0441b
commit
17014c6399
@ -10,26 +10,37 @@ part of 'manual_workout_screen.dart';
|
||||
extension _ManualWorkoutFormParts on _ManualWorkoutScreenState {
|
||||
/// The budget as seen from the selected date: the fullest 7- and 30-day
|
||||
/// windows around it, planned entries included.
|
||||
Widget _budgetBanner(ManualBudget budget) => Text(
|
||||
'Manual around ${dayKey(_date)}: '
|
||||
'${budget.week}/$kManualWorkoutBudgetPer7Days per 7 days · '
|
||||
'${budget.month}/$kManualWorkoutBudgetPer30Days per 30 days',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: AppTextSize.body,
|
||||
),
|
||||
///
|
||||
/// textgate: was "Manual around DATE: 0/2 per 7 days · 0/10 per 30
|
||||
/// days"; now the counts with their window length in days.
|
||||
Widget _budgetBanner(ManualBudget budget) => Row(
|
||||
key: const Key('mw_budget'),
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event_repeat,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
semanticLabel: 'Manual-workout budget',
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
'${budget.week}/$kManualWorkoutBudgetPer7Days ⋅ 7d '
|
||||
'${budget.month}/$kManualWorkoutBudgetPer30Days ⋅ 30d',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: AppTextSize.body,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// "today", "tomorrow" or "in N days" for the selected date.
|
||||
/// `+0` for today, `+1` for tomorrow, `+N` (textgate: was "today" /
|
||||
/// "tomorrow" / "in N days").
|
||||
String _relativeDay() {
|
||||
// Rounded, not truncated: across a DST change two local midnights are
|
||||
// 23 or 25 hours apart.
|
||||
final days = (_date.difference(_today()).inHours / 24).round();
|
||||
return switch (days) {
|
||||
0 => 'today',
|
||||
1 => 'tomorrow',
|
||||
_ => 'in $days days',
|
||||
};
|
||||
return '+$days';
|
||||
}
|
||||
|
||||
/// The workout date and the button that changes it.
|
||||
@ -39,7 +50,8 @@ extension _ManualWorkoutFormParts on _ManualWorkoutScreenState {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Workout date: ${dayKey(_date)} (${_relativeDay()})',
|
||||
// textgate: was "Workout date: DATE (today)".
|
||||
'${dayKey(_date)} ${_relativeDay()}',
|
||||
key: const Key('mw_date'),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
@ -60,19 +72,22 @@ extension _ManualWorkoutFormParts on _ManualWorkoutScreenState {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _section(String title) => Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: AppTextSize.subtitle,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
/// A divider between field groups (textgate: was a word heading such as
|
||||
/// "Basics" or "Reflection").
|
||||
Widget _section() => Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 4),
|
||||
child: Divider(color: Theme.of(context).colorScheme.primary),
|
||||
);
|
||||
|
||||
Widget _text(String key, String label, {int lines = 1}) => Padding(
|
||||
/// A field marked by [icon]; [semanticsLabel] (the old floating label) is
|
||||
/// spoken only. [hint] is a numeral-only placeholder.
|
||||
Widget _text(
|
||||
String key,
|
||||
IconData icon,
|
||||
String semanticsLabel, {
|
||||
int lines = 1,
|
||||
String? hint,
|
||||
}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: TextField(
|
||||
key: Key('mw_$key'),
|
||||
@ -82,14 +97,68 @@ extension _ManualWorkoutFormParts on _ManualWorkoutScreenState {
|
||||
// filled/fillColor/border inherit from the shared inputDecorationTheme
|
||||
// (theme.dart) — only the field-specific label needs setting here.
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(
|
||||
hintText: hint,
|
||||
prefixIcon: Icon(
|
||||
icon,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
semanticLabel: semanticsLabel,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _basicFields() => [
|
||||
_text(
|
||||
'start_time',
|
||||
Icons.play_circle_outline,
|
||||
'Start time (HH:MM)',
|
||||
hint: '--:--',
|
||||
),
|
||||
_text(
|
||||
'end_time',
|
||||
Icons.stop_circle_outlined,
|
||||
'End time (HH:MM)',
|
||||
hint: '--:--',
|
||||
),
|
||||
];
|
||||
|
||||
List<Widget> _logisticsFields() => [
|
||||
_text('location_name', Icons.place, 'Location name'),
|
||||
_text('transport_method', Icons.directions_bus, 'How did you get there?'),
|
||||
_text('cost', Icons.payments, 'Cost (e.g. 40 PLN)'),
|
||||
_text('reservation_phone', Icons.phone, 'Reservation phone (optional)'),
|
||||
];
|
||||
|
||||
List<Widget> _detailFields() => [
|
||||
_text('techniques_practiced', Icons.psychology, 'Techniques (optional)'),
|
||||
_text('warm_up_minutes', Icons.wb_sunny, 'Warm-up (optional)'),
|
||||
_text('pain_or_injury', Icons.healing, 'Pain or injury (optional)'),
|
||||
];
|
||||
|
||||
List<Widget> _reflectionFields() => [
|
||||
_text('went_well', Icons.thumb_up, 'What went well', lines: 3),
|
||||
_text('to_improve', Icons.trending_up, 'What to improve', lines: 3),
|
||||
_text('overall_feeling', Icons.mood, 'Overall feeling', lines: 3),
|
||||
];
|
||||
|
||||
List<Widget> _sportFields() {
|
||||
if (_sport == kSportTableTennis) {
|
||||
return [
|
||||
_text('matches_won', Icons.emoji_events, 'Matches won'),
|
||||
_text('matches_lost', Icons.emoji_events_outlined, 'Matches lost'),
|
||||
_text('sets_won', Icons.add_circle_outline, 'Sets won'),
|
||||
_text('sets_lost', Icons.remove_circle_outline, 'Sets lost'),
|
||||
_text('racket', Icons.sports_tennis, 'Racket used'),
|
||||
_text('balls', Icons.sports_baseball, 'Balls used'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
_text('activity_type_other', Icons.sports, 'What sport/activity'),
|
||||
_text('activity_details', Icons.notes, 'What was done', lines: 3),
|
||||
_text('equipment', Icons.backpack, 'Equipment (optional)'),
|
||||
];
|
||||
}
|
||||
|
||||
/// Reads [key]'s field as an int, treating blank or unparsable as 0.
|
||||
int _int(String key) => int.tryParse(_fields[key]!.text.trim()) ?? 0;
|
||||
|
||||
|
||||
@ -124,9 +124,11 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: Text(
|
||||
'Log Manual Workout',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
// textgate: was "Log Manual Workout".
|
||||
title: Icon(
|
||||
Icons.edit_note,
|
||||
color: colorScheme.onSurface,
|
||||
semanticLabel: 'Log Manual Workout',
|
||||
),
|
||||
),
|
||||
body: !_budgetLoaded
|
||||
@ -139,40 +141,42 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
|
||||
_dateRow(),
|
||||
_budgetBanner(budget),
|
||||
if (exhausted)
|
||||
// textgate: was "Manual-workout budget exhausted for a
|
||||
// window containing <date> — pick another date."
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Manual-workout budget exhausted for a window '
|
||||
'containing ${dayKey(_date)} — pick another date.',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
child: Icon(
|
||||
Icons.block,
|
||||
key: const Key('mw_exhausted'),
|
||||
size: 40,
|
||||
color: colorScheme.error,
|
||||
semanticLabel:
|
||||
'Manual-workout budget exhausted — '
|
||||
'pick another date.',
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
_section('Basics'),
|
||||
_section(),
|
||||
_sportDropdown(),
|
||||
_text('start_time', 'Start time (HH:MM)'),
|
||||
_text('end_time', 'End time (HH:MM)'),
|
||||
_section('Location & logistics'),
|
||||
_text('location_name', 'Location name'),
|
||||
_text('transport_method', 'How did you get there?'),
|
||||
_text('cost', 'Cost (e.g. 40 PLN)'),
|
||||
_text('reservation_phone', 'Reservation phone (optional)'),
|
||||
_section('Activity details'),
|
||||
..._basicFields(),
|
||||
_section(),
|
||||
..._logisticsFields(),
|
||||
_section(),
|
||||
..._sportFields(),
|
||||
_rpeSlider(),
|
||||
_text('techniques_practiced', 'Techniques (optional)'),
|
||||
_text('warm_up_minutes', 'Warm-up (optional)'),
|
||||
_text('pain_or_injury', 'Pain or injury (optional)'),
|
||||
_section('Reflection'),
|
||||
_text('went_well', 'What went well', lines: 3),
|
||||
_text('to_improve', 'What to improve', lines: 3),
|
||||
_text('overall_feeling', 'Overall feeling', lines: 3),
|
||||
..._detailFields(),
|
||||
_section(),
|
||||
..._reflectionFields(),
|
||||
if (_error != null)
|
||||
// textgate: the refusal reason is spoken only; painted
|
||||
// as an error glyph.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
key: const Key('mw_error'),
|
||||
color: colorScheme.error,
|
||||
semanticLabel: _error,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@ -188,24 +192,6 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _sportFields() {
|
||||
if (_sport == kSportTableTennis) {
|
||||
return [
|
||||
_text('matches_won', 'Matches won'),
|
||||
_text('matches_lost', 'Matches lost'),
|
||||
_text('sets_won', 'Sets won'),
|
||||
_text('sets_lost', 'Sets lost'),
|
||||
_text('racket', 'Racket used'),
|
||||
_text('balls', 'Balls used'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
_text('activity_type_other', 'What sport/activity'),
|
||||
_text('activity_details', 'What was done', lines: 3),
|
||||
_text('equipment', 'Equipment (optional)'),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _sportDropdown() => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: DropdownButton<String>(
|
||||
@ -214,8 +200,17 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
// textgate: sport glyphs; the names are spoken only.
|
||||
for (final entry in kSportLabels.entries)
|
||||
DropdownMenuItem(value: entry.key, child: Text(entry.value)),
|
||||
DropdownMenuItem(
|
||||
value: entry.key,
|
||||
child: Icon(
|
||||
entry.key == kSportTableTennis
|
||||
? Icons.sports_tennis
|
||||
: Icons.sports,
|
||||
semanticLabel: entry.value,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _sport = value);
|
||||
@ -227,8 +222,16 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
// textgate: was "RPE 5"; an effort glyph and the number.
|
||||
Icon(
|
||||
Icons.speed,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
semanticLabel: 'RPE',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'RPE ${_rpe.round()}',
|
||||
'${_rpe.round()}',
|
||||
key: const Key('mw_rpe'),
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@ -16,7 +16,7 @@ void main() {
|
||||
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
|
||||
);
|
||||
await pickDay(tester, '14');
|
||||
expect(find.text('Workout date: 2026-07-14 (tomorrow)'), findsOneWidget);
|
||||
expect(find.text('2026-07-14 +1'), findsOneWidget);
|
||||
await fillValidTt(tester);
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pumpAndSettle();
|
||||
@ -44,7 +44,7 @@ void main() {
|
||||
await tester.tap(find.text('20'));
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Workout date: 2026-07-20 (in 7 days)'), findsOneWidget);
|
||||
expect(find.text('2026-07-20 +7'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('cancelling the picker keeps the date', (tester) async {
|
||||
@ -59,7 +59,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Workout date: 2026-07-13 (today)'), findsOneWidget);
|
||||
expect(find.text('2026-07-13 +0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the budget follows the selected date', (tester) async {
|
||||
@ -74,18 +74,13 @@ void main() {
|
||||
clock: fixedManualNow,
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('2/2 per 7 days'), findsOneWidget);
|
||||
expect(find.textContaining('budget exhausted'), findsOneWidget);
|
||||
expect(find.textContaining('2/2 ⋅ 7d'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mw_exhausted')), findsOneWidget);
|
||||
expect(iconLabeled('SUBMIT'), findsNothing);
|
||||
// The date row stays reachable while the window is full.
|
||||
await pickDay(tester, '20');
|
||||
expect(
|
||||
find.text(
|
||||
'Manual around 2026-07-20: 1/2 per 7 days · 2/10 per 30 days',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('budget exhausted'), findsNothing);
|
||||
expect(find.text('1/2 ⋅ 7d 2/10 ⋅ 30d'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mw_exhausted')), findsNothing);
|
||||
expect(iconLabeled('SUBMIT'), findsOneWidget);
|
||||
});
|
||||
|
||||
@ -98,7 +93,7 @@ void main() {
|
||||
await fillValidTt(tester);
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('was deleted earlier'), findsOneWidget);
|
||||
expect(iconLabelContaining('was deleted earlier'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
// A different start time is a different record id.
|
||||
await tester.enterText(find.byKey(const Key('mw_start_time')), '17:30');
|
||||
@ -121,8 +116,8 @@ void main() {
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sync.pushed, isNotNull);
|
||||
expect(find.text('Not saved — push failed: 500'), findsOneWidget);
|
||||
expect(find.text('Log Manual Workout'), findsOneWidget);
|
||||
expect(iconLabeled('Not saved — push failed: 500'), findsOneWidget);
|
||||
expect(iconLabeled('Log Manual Workout'), findsOneWidget);
|
||||
final submit = tester.widget<ElevatedButton>(
|
||||
buttonLabeled<ElevatedButton>('SUBMIT'),
|
||||
);
|
||||
@ -142,7 +137,7 @@ void main() {
|
||||
now = DateTime(2026, 7, 14, 0, 1);
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('is in the past'), findsOneWidget);
|
||||
expect(iconLabelContaining('is in the past'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
// The picker would assert on an initial date before its range.
|
||||
await tester.tap(iconLabeled('CHANGE DATE'));
|
||||
@ -153,7 +148,7 @@ void main() {
|
||||
expect(picker.initialDate, DateTime(2026, 7, 14));
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Workout date: 2026-07-14 (today)'), findsOneWidget);
|
||||
expect(find.text('2026-07-14 +0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a date beyond the window is refused at submit', (
|
||||
@ -171,7 +166,7 @@ void main() {
|
||||
now = DateTime(2026, 7, 12, 12);
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('at most 7 days ahead'), findsOneWidget);
|
||||
expect(iconLabelContaining('at most 7 days ahead'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,13 +13,10 @@ void main() {
|
||||
tester,
|
||||
ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow),
|
||||
);
|
||||
expect(find.text('Log Manual Workout'), findsOneWidget);
|
||||
expect(iconLabeled('Log Manual Workout'), findsOneWidget);
|
||||
expect(iconLabeled('SUBMIT'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Manual around 2026-07-13: 0/2 per 7 days · 0/10 per 30 days'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Workout date: 2026-07-13 (today)'), findsOneWidget);
|
||||
expect(find.text('0/2 ⋅ 7d 0/10 ⋅ 30d'), findsOneWidget);
|
||||
expect(find.text('2026-07-13 +0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides the form when the budget is exhausted', (tester) async {
|
||||
@ -47,7 +44,7 @@ void main() {
|
||||
clock: fixedManualNow,
|
||||
),
|
||||
);
|
||||
expect(find.textContaining('budget exhausted'), findsOneWidget);
|
||||
expect(find.byKey(const Key('mw_exhausted')), findsOneWidget);
|
||||
expect(iconLabeled('SUBMIT'), findsNothing);
|
||||
});
|
||||
|
||||
@ -59,7 +56,7 @@ void main() {
|
||||
);
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('is required'), findsOneWidget);
|
||||
expect(iconLabelContaining('is required'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
});
|
||||
|
||||
@ -87,7 +84,7 @@ void main() {
|
||||
expect(find.byKey(const Key('mw_matches_won')), findsOneWidget);
|
||||
await tester.tap(find.byType(DropdownButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Other').last);
|
||||
await tester.tap(iconLabeled('Other').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('mw_activity_type_other')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mw_matches_won')), findsNothing);
|
||||
@ -98,10 +95,11 @@ void main() {
|
||||
tester,
|
||||
ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow),
|
||||
);
|
||||
expect(find.text('RPE 5'), findsOneWidget);
|
||||
String rpe() => tester.widget<Text>(find.byKey(const Key('mw_rpe'))).data!;
|
||||
expect(rpe(), '5');
|
||||
await tester.drag(find.byType(Slider), const Offset(300, 0));
|
||||
await tester.pump();
|
||||
expect(find.text('RPE 5'), findsNothing);
|
||||
expect(rpe(), isNot('5'));
|
||||
});
|
||||
|
||||
testWidgets('other-sport validation error pushes nothing', (tester) async {
|
||||
@ -112,7 +110,7 @@ void main() {
|
||||
);
|
||||
await tester.tap(find.byType(DropdownButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Other').last);
|
||||
await tester.tap(iconLabeled('Other').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const Key('mw_start_time')), '18:00');
|
||||
await tester.enterText(find.byKey(const Key('mw_end_time')), '19:30');
|
||||
@ -121,7 +119,7 @@ void main() {
|
||||
await tester.enterText(find.byKey(const Key('mw_cost')), '30');
|
||||
await tester.tap(iconLabeled('SUBMIT'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Activity type is required'), findsOneWidget);
|
||||
expect(iconLabelContaining('Activity type is required'), findsOneWidget);
|
||||
expect(sync.pushed, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user