Split home_screen.dart (260) and manual_workout_screen.dart (313)

Both by pattern B, both cut along the same line: whatever does not call
setState can live in a part extension, whatever does must stay on the class.

home_screen.dart 260 -> 228
  home_screen_navigation.dart (42) takes `_openSyncSettings` and
  `_openWorkout` — the two routes the screen pushes, each ending in the same
  `unawaited(_load())` reload. `_load` and `_refreshSyncStatus` stay: both
  setState.

manual_workout_screen.dart 313 -> 245
  manual_workout_form_parts.dart (83) takes the form's non-interactive
  building blocks — `_budgetBanner`, `_section`, `_text` — plus the
  `_int`/`_str` field readers and the `_draft()` assembler they feed. The
  sport dropdown and RPE slider stay on the class; both setState on change.

402 tests pass, dart analyze clean, coverage still 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 22:30:27 +02:00
parent ac7a7afa08
commit b6414c7f1c
4 changed files with 128 additions and 103 deletions

View File

@ -15,6 +15,7 @@ import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/sync_status_card.dart';
part 'home_screen_cards.dart';
part 'home_screen_navigation.dart';
/// Home screen: auto-resumes active sessions and shows done-today status.
class HomeScreen extends StatefulWidget {
@ -104,13 +105,6 @@ class _HomeScreenState extends State<HomeScreen> {
}
/// Opens settings so the user can connect sync, then re-checks on return.
Future<void> _openSyncSettings() async {
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
);
unawaited(_load());
}
/// Runs a sync tick and folds the outcome into the status card.
///
/// Never throws: [WorkoutSyncService.syncNow] reports failures as a
@ -159,32 +153,6 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
Future<void> _openWorkout({bool resume = false}) async {
final storage = StorageService.instance;
Map<String, dynamic>? savedState;
var type = _nextType;
var exercises = _exercises;
if (resume && _savedSession != null) {
savedState = _savedSession;
final savedType = savedState!['workoutType'] as String? ?? _nextType;
type = savedType;
exercises = await storage.getCurrentExercises(savedType);
}
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => WorkoutScreen(
workoutType: type,
exercises: exercises,
savedState: savedState,
),
),
);
unawaited(_load());
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;

View File

@ -0,0 +1,42 @@
// Navigation off the home screen: settings, and starting/resuming a workout.
//
// A `part` so the extension keeps reaching the private `_HomeScreenState`
// fields. Both methods are setState-free — `setState` is `@protected` and
// cannot be called from an extension — and both end by reloading the screen.
part of 'home_screen.dart';
/// The two routes the home screen pushes, and the reload each returns to.
extension _HomeScreenNavigation on _HomeScreenState {
Future<void> _openSyncSettings() async {
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
);
unawaited(_load());
}
Future<void> _openWorkout({bool resume = false}) async {
final storage = StorageService.instance;
Map<String, dynamic>? savedState;
var type = _nextType;
var exercises = _exercises;
if (resume && _savedSession != null) {
savedState = _savedSession;
final savedType = savedState!['workoutType'] as String? ?? _nextType;
type = savedType;
exercises = await storage.getCurrentExercises(savedType);
}
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => WorkoutScreen(
workoutType: type,
exercises: exercises,
savedState: savedState,
),
),
);
unawaited(_load());
}
}

View File

@ -0,0 +1,83 @@
// The small, stateless pieces of the manual-workout form.
//
// A `part` so the extension keeps reaching `_fields` and `context`. All three
// are setState-free — `setState` is `@protected` and unreachable from an
// extension — so the interactive controls (the sport dropdown and the RPE
// slider) stay on the state class.
part of 'manual_workout_screen.dart';
/// The form's non-interactive building blocks.
extension _ManualWorkoutFormParts on _ManualWorkoutScreenState {
Widget _budgetBanner(ManualBudget budget) => Text(
'Manual: ${budget.week}/$kManualWorkoutBudgetPer7Days this week · '
'${budget.month}/$kManualWorkoutBudgetPer30Days this month',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: AppTextSize.body,
),
);
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,
),
),
);
Widget _text(String key, String label, {int lines = 1}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: TextField(
key: Key('mw_$key'),
controller: _fields[key],
maxLines: lines,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
// filled/fillColor/border inherit from the shared inputDecorationTheme
// (theme.dart) — only the field-specific label needs setting here.
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
/// Reads [key]'s field as an int, treating blank or unparseable as 0.
int _int(String key) => int.tryParse(_fields[key]!.text.trim()) ?? 0;
/// Reads [key]'s field verbatim.
String _str(String key) => _fields[key]!.text;
ManualWorkoutDraft _draft() => ManualWorkoutDraft(
sport: _sport,
startTime: _str('start_time'),
endTime: _str('end_time'),
locationName: _str('location_name'),
transportMethod: _str('transport_method'),
cost: _str('cost'),
rpe: _rpe.round(),
wentWell: _str('went_well'),
toImprove: _str('to_improve'),
overallFeeling: _str('overall_feeling'),
reservationPhone: _str('reservation_phone'),
techniquesPracticed: _str('techniques_practiced'),
warmUpMinutes: _str('warm_up_minutes'),
painOrInjury: _str('pain_or_injury').trim().isEmpty
? 'none'
: _str('pain_or_injury'),
matchesWon: _int('matches_won'),
matchesLost: _int('matches_lost'),
setsWon: _int('sets_won'),
setsLost: _int('sets_lost'),
racket: _str('racket'),
balls: _str('balls'),
activityTypeOther: _str('activity_type_other'),
activityDetails: _str('activity_details'),
equipment: _str('equipment'),
);
}

View File

@ -15,6 +15,8 @@ import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
part 'manual_workout_form_parts.dart';
/// A screen that logs an off-app workout and syncs it to the PC.
class ManualWorkoutScreen extends StatefulWidget {
/// Creates the manual-workout form. [syncService]/[clock] are injectable for
@ -93,37 +95,6 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
});
}
int _int(String key) => int.tryParse(_fields[key]!.text.trim()) ?? 0;
String _str(String key) => _fields[key]!.text;
ManualWorkoutDraft _draft() => ManualWorkoutDraft(
sport: _sport,
startTime: _str('start_time'),
endTime: _str('end_time'),
locationName: _str('location_name'),
transportMethod: _str('transport_method'),
cost: _str('cost'),
rpe: _rpe.round(),
wentWell: _str('went_well'),
toImprove: _str('to_improve'),
overallFeeling: _str('overall_feeling'),
reservationPhone: _str('reservation_phone'),
techniquesPracticed: _str('techniques_practiced'),
warmUpMinutes: _str('warm_up_minutes'),
painOrInjury: _str('pain_or_injury').trim().isEmpty
? 'none'
: _str('pain_or_injury'),
matchesWon: _int('matches_won'),
matchesLost: _int('matches_lost'),
setsWon: _int('sets_won'),
setsLost: _int('sets_lost'),
racket: _str('racket'),
balls: _str('balls'),
activityTypeOther: _str('activity_type_other'),
activityDetails: _str('activity_details'),
equipment: _str('equipment'),
);
Future<void> _submit() async {
final draft = _draft();
final error = validateManualWorkout(draft);
@ -233,27 +204,6 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
];
}
Widget _budgetBanner(ManualBudget budget) => Text(
'Manual: ${budget.week}/$kManualWorkoutBudgetPer7Days this week · '
'${budget.month}/$kManualWorkoutBudgetPer30Days this month',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: AppTextSize.body,
),
);
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,
),
),
);
Widget _sportDropdown() => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: DropdownButton<String>(
@ -292,22 +242,4 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
],
),
);
Widget _text(String key, String label, {int lines = 1}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: TextField(
key: Key('mw_$key'),
controller: _fields[key],
maxLines: lines,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
// filled/fillColor/border inherit from the shared inputDecorationTheme
// (theme.dart) — only the field-specific label needs setting here.
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}