Split settings_screen.dart (503 -> 211) — the file-length gate now passes

Last of the ten. The ~190-line `build()` was the bulk, so it became five
private StatelessWidgets rather than list-building methods, per the note in
the todo that a previous attempt to slice them out as a raw widget-list getter
produced unbalanced brackets.

  settings_screen.dart                    211  state, lifecycle, setState paths
  settings_screen_sections.dart           190  SYNC, OFFLINE BACKUP, app bar
  settings_screen_exercise_sections.dart  161  WEIGHTS, TARGET REPS, THRESHOLDS
  settings_screen_widget.dart              89  the public widget + 11 seams
  settings_screen_actions.dart             86  setState-free actions

Every conditional in the new widgets is the one `build()` already had, in the
same shape: no defaulted parameters, no `??` fallbacks, no convenience null
checks. That was deliberate — the earlier workout_screen pass showed a single
added `if (x == null) return` guard is enough to drop the 100% gate, because
it is a line no existing test reaches.

`_openSyncSettings` split the way `_finishWorkout` did: the navigation half is
setState-free and moved to the actions extension, the post-return progression
pull keeps the setState and stays on the class.

`bash ~/utils/scripts/check_file_length.sh --all` now exits 0. 402 tests pass
with a byte-identical name set, dart analyze clean, coverage 100%
(2765/2765).

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:35:16 +02:00
parent b6414c7f1c
commit a7ca5bfbb4
6 changed files with 562 additions and 328 deletions

View File

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

View File

@ -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<FirebaseRestClient?> 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<FirebaseRestClient?> 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<FirebaseAccount?> Function()? accountLoader;
/// Persists the account. See [accountLoader].
final Future<void> Function(FirebaseAccount)? accountSaver;
/// Forgets the account and any cached session. See [accountLoader].
final Future<void> 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<bool> 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<bool> Function()? storageChecker;
/// Opens the system grant page. See [storageChecker].
final Future<bool> 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<ProgressionSyncResult> Function()? progressionPuller;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
part 'settings_screen_widget.dart';
class _SettingsScreenState extends State<SettingsScreen> {
bool _loading = true;
@ -197,20 +119,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// immediately instead of waiting for the next app launch, which also
/// pulls unconditionally at startup.
Future<void> _openSyncSettings() async {
await Navigator.of(context).push<void>(
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<SettingsScreen> {
}
}
Future<void> _openGitHubMirror() async {
await Navigator.of(context).push<void>(
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<SettingsScreen> {
);
}
Future<void> _resetToDefaults() async {
final colorScheme = Theme.of(context).colorScheme;
final ok = await showDialog<bool>(
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<String> get _orderedNames {
final seen = <String>{};
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'),
),
),
],
),
);

View File

@ -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<void> _openGitHubMirror() async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => GitHubMirrorScreen(httpClient: widget.httpClient),
),
);
}
Future<void> _resetToDefaults() async {
final colorScheme = Theme.of(context).colorScheme;
final ok = await showDialog<bool>(
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<String> get _orderedNames {
final seen = <String>{};
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<void> _pushSyncSettingsScreen() async {
await Navigator.of(context).push<void>(
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,
),
),
);
}
}

View File

@ -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<String> orderedNames;
/// Current working weight per exercise; a missing entry renders nothing.
final Map<String, double> 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<String> orderedNames;
/// Target reps per exercise; a missing entry renders nothing.
final Map<String, int> 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<String> orderedNames;
/// Successes before a weight increase, per exercise; defaults to 3.
final Map<String, int> successThresholds;
/// Failures before a weight decrease, per exercise; defaults to 2.
final Map<String, int> 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),
);
}),
],
);
}
}

View File

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

View File

@ -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<FirebaseRestClient?> 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<FirebaseRestClient?> 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<FirebaseAccount?> Function()? accountLoader;
/// Persists the account. See [accountLoader].
final Future<void> Function(FirebaseAccount)? accountSaver;
/// Forgets the account and any cached session. See [accountLoader].
final Future<void> 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<bool> 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<bool> Function()? storageChecker;
/// Opens the system grant page. See [storageChecker].
final Future<bool> 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<ProgressionSyncResult> Function()? progressionPuller;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}