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 d0665c7..7b0a2a4 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 @@ -47,7 +47,7 @@ extension _ManualWorkoutFormParts on _ManualWorkoutScreenState { ), ); - /// Reads [key]'s field as an int, treating blank or unparseable as 0. + /// Reads [key]'s field as an int, treating blank or unparsable as 0. int _int(String key) => int.tryParse(_fields[key]!.text.trim()) ?? 0; /// Reads [key]'s field verbatim. diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index bfcc2b4..29d3235 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -22,90 +22,12 @@ import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; +part 'settings_screen_actions.dart'; +part 'settings_screen_exercise_sections.dart'; part 'settings_screen_rows.dart'; +part 'settings_screen_sections.dart'; part 'settings_screen_thresholds.dart'; - -/// Screen for editing per-exercise thresholds and manual weight overrides. -/// -/// No [BackupSlot] is wired into the shared Sync settings screen: unlike the -/// notes app and home_inventory, workout_app has no user-facing export/import -/// action to hand it. `BackupService.export`/`readBackup` are called only from -/// [StorageService]'s automatic paths (on every weight/threshold write, and -/// once at startup via `restoreFromBackupIfNeeded`) -- there is nothing for a -/// manual "Export"/"Import" button to trigger that isn't already automatic, -/// and a blind manual import would risk clobbering current progression with -/// whatever is on external storage. The storage-permission affordance itself -/// is a device permission, not a sync action, so it stays here rather than -/// moving to either sync screen. -class SettingsScreen extends StatefulWidget { - /// Creates a [SettingsScreen]. - const SettingsScreen({ - super.key, - this.httpClient, - this.firebaseFactory, - this.googleFirebaseFactory, - this.googleAvailable, - this.accountLoader, - this.accountSaver, - this.accountClearer, - this.sessionProbe, - this.storageChecker, - this.storageRequester, - this.progressionPuller, - }); - - /// Injectable HTTP client, passed through to [GitHubMirrorScreen] so its - /// device-flow requests never hit the real network in tests. - final http.Client? httpClient; - - /// Builds the Firebase client. Injected so tests need no platform channel. - final Future Function()? firebaseFactory; - - /// Builds the Firebase backend via Google sign-in. Separate from - /// [firebaseFactory] because it reaches the Google plugin's platform - /// channel, which `flutter test` has no binding for. - final Future Function()? googleFirebaseFactory; - - /// Whether to offer the Google button. Defaults to what the platform - /// supports; injected by tests, whose host reports unsupported. - final bool? googleAvailable; - - /// Keystore accessors for the Firebase account. Injected as a group so the - /// connect/disconnect flows are testable without a platform channel -- - /// `flutter test` has no binding for one. - final Future Function()? accountLoader; - - /// Persists the account. See [accountLoader]. - final Future Function(FirebaseAccount)? accountSaver; - - /// Forgets the account and any cached session. See [accountLoader]. - final Future Function()? accountClearer; - - /// Whether a Firebase session is stored. See [accountLoader]. - /// - /// Separate from [accountLoader] because the two answer different - /// questions: the account marker is bookkeeping, the session is the - /// credential. A device can hold the second without the first, and - /// reporting only the first is what made a syncing phone read as - /// "not connected". - final Future Function()? sessionProbe; - - /// Reads whether storage permission is held. Injected for the same reason as - /// [accountLoader]: `permission_handler` is a platform channel, and calling - /// it unguarded in `initState` made every settings-screen test throw. - final Future Function()? storageChecker; - - /// Opens the system grant page. See [storageChecker]. - final Future Function()? storageRequester; - - /// Pulls progression after returning from Sync settings. See - /// [_openSyncSettings] for why this fires on pop rather than being hooked - /// into the shared screen's connect flow. - final Future Function()? progressionPuller; - - @override - State createState() => _SettingsScreenState(); -} +part 'settings_screen_widget.dart'; class _SettingsScreenState extends State { bool _loading = true; @@ -197,20 +119,7 @@ class _SettingsScreenState extends State { /// immediately instead of waiting for the next app launch, which also /// pulls unconditionally at startup. Future _openSyncSettings() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SyncSettingsScreen( - accountLoader: widget.accountLoader ?? loadAccount, - accountSaver: widget.accountSaver ?? saveAccount, - accountClearer: widget.accountClearer ?? clearAccount, - sessionProbe: widget.sessionProbe ?? isFirebaseConfigured, - firebaseFactory: widget.firebaseFactory ?? openFirebase, - googleFirebaseFactory: - widget.googleFirebaseFactory ?? openFirebaseWithGoogle, - googleAvailable: widget.googleAvailable ?? googleSignInSupported, - ), - ), - ); + await _pushSyncSettingsScreen(); if (!mounted) return; final restored = await (widget.progressionPuller ?? @@ -225,14 +134,6 @@ class _SettingsScreenState extends State { } } - Future _openGitHubMirror() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => GitHubMirrorScreen(httpClient: widget.httpClient), - ), - ); - } - void _onWeightChanged(String name, double value) { setState(() => _weights[name] = value); _weightTimers[name]?.cancel(); @@ -261,241 +162,48 @@ class _SettingsScreenState extends State { ); } - Future _resetToDefaults() async { - final colorScheme = Theme.of(context).colorScheme; - final ok = await showDialog( - context: context, - builder: (_) => AlertDialog( - backgroundColor: colorScheme.surfaceContainerHigh, - title: Text( - 'Reset to defaults?', - style: TextStyle(color: colorScheme.onSurface), - ), - content: Text( - 'All weights and thresholds will be reset. ' - 'Streak counters will be cleared.', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text( - 'Cancel', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text('Reset', style: TextStyle(color: colorScheme.error)), - ), - ], - ), - ); - if (ok == true) { - for (final name in _orderedNames) { - await StorageService.instance.resetExerciseToDefaults(name); - } - await _load(); - } - } - - List get _orderedNames { - final seen = {}; - return [ - ...workoutA, - ...workoutB, - ].map((e) => e.name).where(seen.add).toList(); - } - @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar( - backgroundColor: colorScheme.surfaceContainerHigh, - title: Text( - 'Settings', - style: TextStyle(color: colorScheme.onSurface), - ), - iconTheme: IconThemeData(color: colorScheme.onSurface), - actions: [ - TextButton( - onPressed: _loading ? null : _resetToDefaults, - child: Text( - 'Reset defaults', - style: TextStyle(color: colorScheme.error), - ), - ), - ], + appBar: _SettingsAppBar( + loading: _loading, + onReset: () => unawaited(_resetToDefaults()), ), body: _loading ? const Center(child: CircularProgressIndicator()) : ListView( padding: const EdgeInsets.all(16), children: [ - const _SectionHeader('WEIGHTS'), - const SizedBox(height: 4), - Text( - 'Override current working weight. ' - 'Resets streak counters. Rounded to 2.5 kg.', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - const SizedBox(height: 12), - ..._orderedNames.map((name) { - final w = _weights[name]; - if (w == null) return const SizedBox.shrink(); - return _WeightRow( - name: name, - weight: w, - onChanged: (v) => _onWeightChanged(name, v), - ); - }), - const SizedBox(height: 20), - const _SectionHeader('TARGET REPS'), - const SizedBox(height: 4), - Text( - 'Override target reps per set. Resets streak counters. ' - 'Progression only ever raises reps, so this is the only way ' - 'to lower one.', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - const SizedBox(height: 12), - ..._orderedNames.map((name) { - final r = _reps[name]; - if (r == null) return const SizedBox.shrink(); - return _RepsRow( - name: name, - reps: r, - onChanged: (v) => _onRepsChanged(name, v), - ); - }), - const SizedBox(height: 20), - const _SectionHeader('PROGRESSION THRESHOLDS'), - const SizedBox(height: 4), - Text( - 'Consecutive successes (↑) or failures (↓) ' - 'before weight changes.', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - const SizedBox(height: 12), - ..._orderedNames.map((name) { - final sThresh = _successThresholds[name] ?? 3; - final fThresh = _failThresholds[name] ?? 2; - return _ExerciseThresholdCard( - name: name, - successThreshold: sThresh, - failThreshold: fThresh, - onSuccessChanged: (v) => - _onThresholdChanged(name, v, _failThresholds[name]!), - onFailChanged: (v) => - _onThresholdChanged(name, _successThresholds[name]!, v), - ); - }), - const SizedBox(height: 20), - const _SectionHeader('SYNC'), - const SizedBox(height: 4), - if (_progressionStatus != null) ...[ - Text( - _progressionStatus!, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - const SizedBox(height: 8), - ], - Card( - margin: EdgeInsets.zero, - color: colorScheme.surfaceContainerHigh, - child: Column( - children: [ - ListTile( - title: Text( - 'Sync settings', - style: TextStyle(color: colorScheme.onSurface), - ), - subtitle: Text( - 'Firebase sync', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - ), - ), - trailing: Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - ), - onTap: () => unawaited(_openSyncSettings()), - ), - Divider( - height: 1, - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.2, - ), - ), - ListTile( - title: Text( - 'Advanced sync (GitHub)', - style: TextStyle(color: colorScheme.onSurface), - ), - subtitle: Text( - 'Cutover mirror — not recommended', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - ), - ), - trailing: Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - ), - onTap: () => unawaited(_openGitHubMirror()), - ), - ], - ), + _WeightsSection( + orderedNames: _orderedNames, + weights: _weights, + onWeightChanged: _onWeightChanged, ), const SizedBox(height: 20), - const _SectionHeader('OFFLINE BACKUP'), - const SizedBox(height: 4), - Text( - _storageGranted - ? 'Granted. Progression is also written to ' - '$kBackupPath, so it survives a reinstall even ' - 'with no network.' - : 'Optional. Progression is restored from Firebase on ' - 'a fresh install, so this is a second, offline ' - 'copy — not a requirement. Granting it also keeps ' - 'a readable snapshot at $kBackupPath.', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), + _RepsSection( + orderedNames: _orderedNames, + reps: _reps, + onRepsChanged: _onRepsChanged, + ), + const SizedBox(height: 20), + _ThresholdsSection( + orderedNames: _orderedNames, + successThresholds: _successThresholds, + failThresholds: _failThresholds, + onThresholdChanged: (name, success, fail) => + unawaited(_onThresholdChanged(name, success, fail)), + ), + const SizedBox(height: 20), + _SyncSection( + progressionStatus: _progressionStatus, + onOpenSyncSettings: () => unawaited(_openSyncSettings()), + onOpenGitHubMirror: () => unawaited(_openGitHubMirror()), + ), + const SizedBox(height: 20), + _OfflineBackupSection( + storageGranted: _storageGranted, + onGrantStorage: _grantStorage, ), - const SizedBox(height: 12), - if (_storageGranted) - const Row( - children: [ - Icon(Icons.check_circle, size: 20), - SizedBox(width: 8), - Expanded(child: Text('Storage permission granted')), - ], - ) - else - Align( - alignment: Alignment.centerLeft, - child: ElevatedButton.icon( - onPressed: _grantStorage, - icon: const Icon(Icons.sd_storage), - label: const Text('Grant storage permission'), - ), - ), ], ), ); diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_actions.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_actions.dart new file mode 100644 index 0000000..2a8c986 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_actions.dart @@ -0,0 +1,86 @@ +// Settings actions that need no `setState`: the GitHub-mirror route, the +// reset-to-defaults confirmation, and the exercise ordering both use. +// +// A `part` so the extension keeps reaching `_load`, `widget` and `context`. +// `setState` is `@protected` and unreachable from an extension, which is +// exactly the line these three sit on the safe side of — each either +// navigates or delegates to `_load`, and `_load` owns the setState. +part of 'settings_screen.dart'; + +/// The settings screen's setState-free actions. +extension _SettingsScreenActions on _SettingsScreenState { + Future _openGitHubMirror() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GitHubMirrorScreen(httpClient: widget.httpClient), + ), + ); + } + + Future _resetToDefaults() async { + final colorScheme = Theme.of(context).colorScheme; + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: Text( + 'Reset to defaults?', + style: TextStyle(color: colorScheme.onSurface), + ), + content: Text( + 'All weights and thresholds will be reset. ' + 'Streak counters will be cleared.', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text( + 'Cancel', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text('Reset', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + if (ok == true) { + for (final name in _orderedNames) { + await StorageService.instance.resetExerciseToDefaults(name); + } + await _load(); + } + } + + List get _orderedNames { + final seen = {}; + return [ + ...workoutA, + ...workoutB, + ].map((e) => e.name).where(seen.add).toList(); + } + + /// Pushes the shared sync-settings screen, wiring every injectable seam. + /// + /// Split from [_SettingsScreenState._openSyncSettings], which keeps the + /// post-return progression pull because that half calls `setState`. + Future _pushSyncSettingsScreen() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SyncSettingsScreen( + accountLoader: widget.accountLoader ?? loadAccount, + accountSaver: widget.accountSaver ?? saveAccount, + accountClearer: widget.accountClearer ?? clearAccount, + sessionProbe: widget.sessionProbe ?? isFirebaseConfigured, + firebaseFactory: widget.firebaseFactory ?? openFirebase, + googleFirebaseFactory: + widget.googleFirebaseFactory ?? openFirebaseWithGoogle, + googleAvailable: widget.googleAvailable ?? googleSignInSupported, + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_sections.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_sections.dart new file mode 100644 index 0000000..35d3507 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_sections.dart @@ -0,0 +1,161 @@ +// The WEIGHTS, TARGET REPS and PROGRESSION THRESHOLDS sections. +// +// A `part` for the same reason as settings_screen_sections.dart: these stay +// library-private. Each takes the ordered names plus the one map it reads, so +// every `if (x == null)` and `?? default` below is the one the original +// `build()` already had — the coverage gate is at 100% and a new branch is a +// line no test reaches. +part of 'settings_screen.dart'; + +/// The WEIGHTS section: per-exercise working-weight overrides. +class _WeightsSection extends StatelessWidget { + const _WeightsSection({ + required this.orderedNames, + required this.weights, + required this.onWeightChanged, + }); + + /// Exercise names in display order. + final List orderedNames; + + /// Current working weight per exercise; a missing entry renders nothing. + final Map weights; + + /// Invoked with (name, newWeight) when a row changes. + final void Function(String name, double value) onWeightChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionHeader('WEIGHTS'), + const SizedBox(height: 4), + Text( + 'Override current working weight. ' + 'Resets streak counters. Rounded to 2.5 kg.', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + const SizedBox(height: 12), + ...orderedNames.map((name) { + final w = weights[name]; + if (w == null) return const SizedBox.shrink(); + return _WeightRow( + name: name, + weight: w, + onChanged: (v) => onWeightChanged(name, v), + ); + }), + ], + ); + } +} + +/// The TARGET REPS section: per-exercise target-rep overrides. +class _RepsSection extends StatelessWidget { + const _RepsSection({ + required this.orderedNames, + required this.reps, + required this.onRepsChanged, + }); + + /// Exercise names in display order. + final List orderedNames; + + /// Target reps per exercise; a missing entry renders nothing. + final Map reps; + + /// Invoked with (name, newReps) when a row changes. + final void Function(String name, int value) onRepsChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionHeader('TARGET REPS'), + const SizedBox(height: 4), + Text( + 'Override target reps per set. Resets streak counters. ' + 'Progression only ever raises reps, so this is the only way ' + 'to lower one.', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + const SizedBox(height: 12), + ...orderedNames.map((name) { + final r = reps[name]; + if (r == null) return const SizedBox.shrink(); + return _RepsRow( + name: name, + reps: r, + onChanged: (v) => onRepsChanged(name, v), + ); + }), + ], + ); + } +} + +/// The PROGRESSION THRESHOLDS section: streak lengths before a weight change. +class _ThresholdsSection extends StatelessWidget { + const _ThresholdsSection({ + required this.orderedNames, + required this.successThresholds, + required this.failThresholds, + required this.onThresholdChanged, + }); + + /// Exercise names in display order. + final List orderedNames; + + /// Successes before a weight increase, per exercise; defaults to 3. + final Map successThresholds; + + /// Failures before a weight decrease, per exercise; defaults to 2. + final Map failThresholds; + + /// Invoked with (name, successThreshold, failThreshold) on either change. + final void Function(String name, int success, int fail) onThresholdChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionHeader('PROGRESSION THRESHOLDS'), + const SizedBox(height: 4), + Text( + 'Consecutive successes (↑) or failures (↓) ' + 'before weight changes.', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + const SizedBox(height: 12), + ...orderedNames.map((name) { + final sThresh = successThresholds[name] ?? 3; + final fThresh = failThresholds[name] ?? 2; + return _ExerciseThresholdCard( + name: name, + successThreshold: sThresh, + failThreshold: fThresh, + onSuccessChanged: (v) => + onThresholdChanged(name, v, failThresholds[name]!), + onFailChanged: (v) => + onThresholdChanged(name, successThresholds[name]!, v), + ); + }), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart new file mode 100644 index 0000000..077e628 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart @@ -0,0 +1,190 @@ +// The SYNC and OFFLINE BACKUP sections of the settings list. +// +// A `part` so these stay library-private: making them public would trip +// `public_member_api_docs` and widen the app's API for no reason. +// +// Both are pure widgets rather than list-building methods on the state class. +// Every conditional here is the one the original `build()` had, in the same +// shape — no defaulted parameters, no `??` fallbacks, no extra null checks — +// because a new branch is a line no existing test covers and the coverage +// gate is at 100%. +part of 'settings_screen.dart'; + +/// The SYNC section: progression status line plus the two sync routes. +class _SyncSection extends StatelessWidget { + const _SyncSection({ + required this.progressionStatus, + required this.onOpenSyncSettings, + required this.onOpenGitHubMirror, + }); + + /// Last progression-sync outcome, or null when there is nothing to report. + final String? progressionStatus; + + /// Opens the Firebase sync settings screen. + final VoidCallback onOpenSyncSettings; + + /// Opens the legacy GitHub mirror screen. + final VoidCallback onOpenGitHubMirror; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionHeader('SYNC'), + const SizedBox(height: 4), + if (progressionStatus != null) ...[ + Text( + progressionStatus!, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + const SizedBox(height: 8), + ], + Card( + margin: EdgeInsets.zero, + color: colorScheme.surfaceContainerHigh, + child: Column( + children: [ + ListTile( + title: Text( + 'Sync settings', + style: TextStyle(color: colorScheme.onSurface), + ), + subtitle: Text( + 'Firebase sync', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), + onTap: onOpenSyncSettings, + ), + Divider( + height: 1, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.2), + ), + ListTile( + title: Text( + 'Advanced sync (GitHub)', + style: TextStyle(color: colorScheme.onSurface), + ), + subtitle: Text( + 'Cutover mirror — not recommended', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), + onTap: onOpenGitHubMirror, + ), + ], + ), + ), + ], + ); + } +} + +/// The OFFLINE BACKUP section: what the permission buys, and how to grant it. +class _OfflineBackupSection extends StatelessWidget { + const _OfflineBackupSection({ + required this.storageGranted, + required this.onGrantStorage, + }); + + /// Whether storage permission is already held. + final bool storageGranted; + + /// Asks the OS for storage permission. + final VoidCallback onGrantStorage; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionHeader('OFFLINE BACKUP'), + const SizedBox(height: 4), + Text( + storageGranted + ? 'Granted. Progression is also written to ' + '$kBackupPath, so it survives a reinstall even ' + 'with no network.' + : 'Optional. Progression is restored from Firebase on ' + 'a fresh install, so this is a second, offline ' + 'copy — not a requirement. Granting it also keeps ' + 'a readable snapshot at $kBackupPath.', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + const SizedBox(height: 12), + if (storageGranted) + const Row( + children: [ + Icon(Icons.check_circle, size: 20), + SizedBox(width: 8), + Expanded(child: Text('Storage permission granted')), + ], + ) + else + Align( + alignment: Alignment.centerLeft, + child: ElevatedButton.icon( + onPressed: onGrantStorage, + icon: const Icon(Icons.sd_storage), + label: const Text('Grant storage permission'), + ), + ), + ], + ); + } +} + +/// App bar for the settings screen, with the Reset-defaults action. +/// +/// Reset is disabled while [loading] so it cannot fire against half-read +/// state — the same `_loading ? null : ...` the original `build()` used. +class _SettingsAppBar extends StatelessWidget implements PreferredSizeWidget { + const _SettingsAppBar({required this.loading, required this.onReset}); + + /// Whether settings are still being read; disables Reset while true. + final bool loading; + + /// Invoked when the user taps Reset defaults. + final VoidCallback onReset; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return AppBar( + backgroundColor: colorScheme.surfaceContainerHigh, + title: Text( + 'Settings', + style: TextStyle(color: colorScheme.onSurface), + ), + iconTheme: IconThemeData(color: colorScheme.onSurface), + actions: [ + TextButton( + onPressed: loading ? null : onReset, + child: Text( + 'Reset defaults', + style: TextStyle(color: colorScheme.error), + ), + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart new file mode 100644 index 0000000..accfa04 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart @@ -0,0 +1,89 @@ +// The public SettingsScreen widget and its injectable seams. +// +// A `part` so the widget stays in the same library as its private state class +// while the constructor and its eleven test seams — each needing its own doc +// comment under `public_member_api_docs` — live somewhere the state logic can +// be read without scrolling past them. +part of 'settings_screen.dart'; + +/// Screen for editing per-exercise thresholds and manual weight overrides. +/// +/// No [BackupSlot] is wired into the shared Sync settings screen: unlike the +/// notes app and home_inventory, workout_app has no user-facing export/import +/// action to hand it. `BackupService.export`/`readBackup` are called only from +/// [StorageService]'s automatic paths (on every weight/threshold write, and +/// once at startup via `restoreFromBackupIfNeeded`) -- there is nothing for a +/// manual "Export"/"Import" button to trigger that isn't already automatic, +/// and a blind manual import would risk clobbering current progression with +/// whatever is on external storage. The storage-permission affordance itself +/// is a device permission, not a sync action, so it stays here rather than +/// moving to either sync screen. +class SettingsScreen extends StatefulWidget { + /// Creates a [SettingsScreen]. + const SettingsScreen({ + super.key, + this.httpClient, + this.firebaseFactory, + this.googleFirebaseFactory, + this.googleAvailable, + this.accountLoader, + this.accountSaver, + this.accountClearer, + this.sessionProbe, + this.storageChecker, + this.storageRequester, + this.progressionPuller, + }); + + /// Injectable HTTP client, passed through to [GitHubMirrorScreen] so its + /// device-flow requests never hit the real network in tests. + final http.Client? httpClient; + + /// Builds the Firebase client. Injected so tests need no platform channel. + final Future Function()? firebaseFactory; + + /// Builds the Firebase backend via Google sign-in. Separate from + /// [firebaseFactory] because it reaches the Google plugin's platform + /// channel, which `flutter test` has no binding for. + final Future Function()? googleFirebaseFactory; + + /// Whether to offer the Google button. Defaults to what the platform + /// supports; injected by tests, whose host reports unsupported. + final bool? googleAvailable; + + /// Keystore accessors for the Firebase account. Injected as a group so the + /// connect/disconnect flows are testable without a platform channel -- + /// `flutter test` has no binding for one. + final Future Function()? accountLoader; + + /// Persists the account. See [accountLoader]. + final Future Function(FirebaseAccount)? accountSaver; + + /// Forgets the account and any cached session. See [accountLoader]. + final Future Function()? accountClearer; + + /// Whether a Firebase session is stored. See [accountLoader]. + /// + /// Separate from [accountLoader] because the two answer different + /// questions: the account marker is bookkeeping, the session is the + /// credential. A device can hold the second without the first, and + /// reporting only the first is what made a syncing phone read as + /// "not connected". + final Future Function()? sessionProbe; + + /// Reads whether storage permission is held. Injected for the same reason as + /// [accountLoader]: `permission_handler` is a platform channel, and calling + /// it unguarded in `initState` made every settings-screen test throw. + final Future Function()? storageChecker; + + /// Opens the system grant page. See [storageChecker]. + final Future Function()? storageRequester; + + /// Pulls progression after returning from Sync settings. See + /// [_openSyncSettings] for why this fires on pop rather than being hooked + /// into the shared screen's connect flow. + final Future Function()? progressionPuller; + + @override + State createState() => _SettingsScreenState(); +}