mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
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:
parent
b6414c7f1c
commit
a7ca5bfbb4
@ -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.
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user