From 17014c6399da1f89cff1ba4dde4b078846cfbf65 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 01:28:08 +0100 Subject: [PATCH] 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 --- .../screens/manual_workout_form_parts.dart | 125 ++++++++++++++---- .../lib/screens/manual_workout_screen.dart | 95 ++++++------- .../screens/manual_workout_date_test.dart | 31 ++--- .../screens/manual_workout_screen_test.dart | 24 ++-- 4 files changed, 170 insertions(+), 105 deletions(-) diff --git a/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart b/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart index 4bab804..e9410e2 100644 --- a/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart +++ b/stronglift_replacement/workout_app/lib/screens/manual_workout_form_parts.dart @@ -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 _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 _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 _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 _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 _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; diff --git a/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart index 9b4f184..902a298 100644 --- a/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart @@ -124,9 +124,11 @@ class _ManualWorkoutScreenState extends State { 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 { _dateRow(), _budgetBanner(budget), if (exhausted) + // textgate: was "Manual-workout budget exhausted for a + // window containing — 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 { ); } - List _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( @@ -214,8 +200,17 @@ class _ManualWorkoutScreenState extends State { 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 { 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( diff --git a/stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart b/stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart index 7d76fd7..171de6d 100644 --- a/stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart +++ b/stronglift_replacement/workout_app/test/screens/manual_workout_date_test.dart @@ -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( buttonLabeled('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); }); }); diff --git a/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart b/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart index ee36eef..96eb854 100644 --- a/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart @@ -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)); 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(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)); 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); }); }