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:
Krzysztof Rudnicki 2026-11-11 01:28:08 +01:00
parent b584c0441b
commit 17014c6399
4 changed files with 170 additions and 105 deletions

View File

@ -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;

View File

@ -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(

View File

@ -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);
});
});

View File

@ -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);
});
}