mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 14:07:41 +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;
|
int _int(String key) => int.tryParse(_fields[key]!.text.trim()) ?? 0;
|
||||||
|
|
||||||
/// Reads [key]'s field verbatim.
|
/// 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/services/storage_service.dart';
|
||||||
import 'package:workout_app/ui/theme.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_rows.dart';
|
||||||
|
part 'settings_screen_sections.dart';
|
||||||
part 'settings_screen_thresholds.dart';
|
part 'settings_screen_thresholds.dart';
|
||||||
|
part 'settings_screen_widget.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();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
@ -197,20 +119,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
/// immediately instead of waiting for the next app launch, which also
|
/// immediately instead of waiting for the next app launch, which also
|
||||||
/// pulls unconditionally at startup.
|
/// pulls unconditionally at startup.
|
||||||
Future<void> _openSyncSettings() async {
|
Future<void> _openSyncSettings() async {
|
||||||
await Navigator.of(context).push<void>(
|
await _pushSyncSettingsScreen();
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final restored =
|
final restored =
|
||||||
await (widget.progressionPuller ??
|
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) {
|
void _onWeightChanged(String name, double value) {
|
||||||
setState(() => _weights[name] = value);
|
setState(() => _weights[name] = value);
|
||||||
_weightTimers[name]?.cancel();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: _SettingsAppBar(
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
loading: _loading,
|
||||||
title: Text(
|
onReset: () => unawaited(_resetToDefaults()),
|
||||||
'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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
const _SectionHeader('WEIGHTS'),
|
_WeightsSection(
|
||||||
const SizedBox(height: 4),
|
orderedNames: _orderedNames,
|
||||||
Text(
|
weights: _weights,
|
||||||
'Override current working weight. '
|
onWeightChanged: _onWeightChanged,
|
||||||
'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()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const _SectionHeader('OFFLINE BACKUP'),
|
_RepsSection(
|
||||||
const SizedBox(height: 4),
|
orderedNames: _orderedNames,
|
||||||
Text(
|
reps: _reps,
|
||||||
_storageGranted
|
onRepsChanged: _onRepsChanged,
|
||||||
? 'Granted. Progression is also written to '
|
),
|
||||||
'$kBackupPath, so it survives a reinstall even '
|
const SizedBox(height: 20),
|
||||||
'with no network.'
|
_ThresholdsSection(
|
||||||
: 'Optional. Progression is restored from Firebase on '
|
orderedNames: _orderedNames,
|
||||||
'a fresh install, so this is a second, offline '
|
successThresholds: _successThresholds,
|
||||||
'copy — not a requirement. Granting it also keeps '
|
failThresholds: _failThresholds,
|
||||||
'a readable snapshot at $kBackupPath.',
|
onThresholdChanged: (name, success, fail) =>
|
||||||
style: TextStyle(
|
unawaited(_onThresholdChanged(name, success, fail)),
|
||||||
color: colorScheme.onSurfaceVariant,
|
),
|
||||||
fontSize: AppTextSize.caption,
|
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