Split storage_service.dart (781) into part extensions

storage_service.dart            the class: open, settings, active session
  storage_service_schema.dart     create / migrate / seed
  storage_service_exercises.dart  read+write a single exercise's row
  storage_service_sessions.dart   apply progression, record sessions
  storage_service_backup.dart     backup, restore, sync markers

Dart cannot continue a class body across files, so unlike history_screen these
are `extension`s in `part` files. Library privacy still applies inside a part,
so they reach `_db` and `_getSetting` unchanged and every caller still writes
`StorageService.instance.foo()` -- no public API change, nothing for
public_member_api_docs to demand.

Doing this exposed a bug in scripts/check_silent_failures_dart.py: its bare
`on Foo {` pattern (the catch clause form) also matched
`extension StorageServiceBackup on StorageService {`, so it reported the
declarations as swallowed exceptions. It now requires the try block's closing
`}` first. Verified both directions on a fixture: a real silent
`} on FormatException {` is still reported, the extension is not.

402 Dart tests still pass; flutter coverage still 100%; dart analyze clean.

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 21:12:08 +02:00
parent 38e8364306
commit d418994f6f
6 changed files with 590 additions and 543 deletions

View File

@ -51,8 +51,12 @@ _LOUD_CALLS = (
"ScaffoldMessenger",
)
# `} on Foo catch (e) {`, `} catch (e) {`, `on Foo {`.
_CATCH_RE = re.compile(r"\bcatch\s*\(|\bon\s+\w+\s*\{")
# `} on Foo catch (e) {`, `} catch (e) {`, `} on Foo {`.
#
# The bare `on Foo {` form requires the preceding `}` of the try block: without
# it this also matched `extension StorageServiceBackup on StorageService {`,
# reporting an extension declaration as a swallowed exception.
_CATCH_RE = re.compile(r"\bcatch\s*\(|\}\s*on\s+\w+\s*\{")
# `.catchError((_) {})` and friends -- an empty body is always silent.
_EMPTY_CATCH_ERROR_RE = re.compile(r"\.catchError\(\s*\([^)]*\)\s*(?:=>\s*)?\{\s*\}")

View File

@ -11,6 +11,11 @@ import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/progression_sync_service.dart';
part 'storage_service_backup.dart';
part 'storage_service_exercises.dart';
part 'storage_service_schema.dart';
part 'storage_service_sessions.dart';
/// Per-exercise progression state stored in SQLite.
class ExerciseState {
/// Creates an [ExerciseState] with all required progression fields.
@ -114,94 +119,6 @@ class StorageService {
await _seedDefaultsIfNeeded();
}
Future<void> _createSchema(Database db, int version) async {
await db.execute('''
CREATE TABLE exercise_state (
name TEXT PRIMARY KEY,
weight REAL NOT NULL,
reps INTEGER NOT NULL,
success_streak INTEGER NOT NULL DEFAULT 0,
fail_streak INTEGER NOT NULL DEFAULT 0,
max_weight REAL NOT NULL,
success_threshold INTEGER NOT NULL DEFAULT 3,
fail_threshold INTEGER NOT NULL DEFAULT 2
)
''');
await db.execute('''
CREATE TABLE workout_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
workout_type TEXT NOT NULL,
duration_seconds INTEGER NOT NULL,
succeeded INTEGER NOT NULL,
json TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE active_session (
id INTEGER PRIMARY KEY CHECK (id = 1),
json TEXT NOT NULL
)
''');
}
Future<void> _migrateSchema(
Database db,
int oldVersion,
int newVersion,
) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE exercise_state '
'ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
);
await db.execute(
'ALTER TABLE exercise_state '
'ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
);
}
if (oldVersion < 3) {
await db.execute(
'CREATE TABLE IF NOT EXISTS settings '
'(key TEXT PRIMARY KEY, value TEXT NOT NULL)',
);
await db.execute(
'CREATE TABLE IF NOT EXISTS active_session '
'(id INTEGER PRIMARY KEY CHECK (id = 1), json TEXT NOT NULL)',
);
}
}
Future<void> _seedDefaultsIfNeeded() async {
for (final ex in [...workoutA, ...workoutB]) {
final rows = await _db.query(
'exercise_state',
where: 'name = ?',
whereArgs: [ex.name],
);
if (rows.isEmpty) {
await _db.insert('exercise_state', {
'name': ex.name,
'weight': ex.weight,
'reps': ex.reps,
'success_streak': 0,
'fail_streak': 0,
'max_weight': ex.maxWeight,
'success_threshold': 3,
'fail_threshold': 2,
});
}
}
}
// ── Settings ───────────────────────────────────────────────────────────────
Future<String?> _getSetting(String key) async {
final rows = await _db.query(
'settings',
@ -284,303 +201,6 @@ class StorageService {
await BackupService.instance.exportActiveSession(null);
}
// ── Exercise state ─────────────────────────────────────────────────────────
/// Returns the progression state for [name], or null if not found.
Future<ExerciseState?> getExerciseState(String name) async {
final rows = await _db.query(
'exercise_state',
where: 'name = ?',
whereArgs: [name],
);
if (rows.isEmpty) return null;
final r = rows.first;
return ExerciseState(
name: r['name']! as String,
weight: r['weight']! as double,
reps: r['reps']! as int,
successStreak: r['success_streak']! as int,
failStreak: r['fail_streak']! as int,
maxWeight: r['max_weight']! as double,
successThreshold: r['success_threshold'] as int? ?? 3,
failThreshold: r['fail_threshold'] as int? ?? 2,
);
}
/// Returns progression states for every exercise across both plans.
Future<List<ExerciseState>> getAllExerciseStates() async {
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
final states = <ExerciseState>[];
for (final name in allNames) {
final s = await getExerciseState(name);
if (s != null) states.add(s);
}
return states;
}
/// Updates the streak thresholds for exercise [name].
Future<void> setExerciseThresholds(
String name, {
required int successThreshold,
required int failThreshold,
}) async {
await _db.update(
'exercise_state',
{
'success_threshold': successThreshold,
'fail_threshold': failThreshold,
},
where: 'name = ?',
whereArgs: [name],
);
}
/// Sets the target reps for [name], resetting streaks.
///
/// Progression can only ever raise reps (by one, and only once an exercise
/// is at its max weight), so without this there is no way to lower a rep
/// target that a defaults re-seed set wrong -- the 2026-08-05 wipe dropped
/// Situp from 31 to the default 30 with no way back short of editing the DB.
Future<void> setExerciseReps(String name, int reps) async {
await _db.update(
'exercise_state',
{'reps': reps, 'success_streak': 0, 'fail_streak': 0},
where: 'name = ?',
whereArgs: [name],
);
unawaited(_backupNow());
}
/// Overwrites [state] wholesale, inserting it when the row is absent.
///
/// Used by the Firebase progression restore, which carries a complete record
/// (weight, reps, both streaks, max weight, both thresholds) and must land it
/// atomically — a field-by-field update would leave a half-restored row if it
/// failed partway. Unlike [setExerciseWeight] this deliberately does NOT
/// reset streaks: the streaks are part of what is being restored.
///
/// Does not trigger a backup write. The caller is restoring INTO a
/// freshly-seeded database, and `_backupNow()` mid-restore is what previously
/// let default rows reach the only off-device copy.
Future<void> replaceExerciseState(ExerciseState state) async {
await _db.insert('exercise_state', {
'name': state.name,
'weight': state.weight,
'reps': state.reps,
'success_streak': state.successStreak,
'fail_streak': state.failStreak,
'max_weight': state.maxWeight,
'success_threshold': state.successThreshold,
'fail_threshold': state.failThreshold,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
/// Sets the working weight for [name], resetting streaks.
Future<void> setExerciseWeight(String name, double weight) async {
await _db.update(
'exercise_state',
{'weight': weight, 'success_streak': 0, 'fail_streak': 0},
where: 'name = ?',
whereArgs: [name],
);
unawaited(_backupNow());
}
/// Returns exercises for [workoutType] with weights/reps from stored state.
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
final template = workoutType == 'A' ? workoutA : workoutB;
final result = <Exercise>[];
for (final ex in template) {
final state = await getExerciseState(ex.name);
if (state == null) {
// Defensive: _seedDefaultsIfNeeded guarantees every template exercise
// has a state row, so this branch is unreachable in practice.
// coverage:ignore-start
result.add(ex);
// coverage:ignore-end
} else {
result.add(ex.copyWith(weight: state.weight, reps: state.reps));
}
}
return result;
}
/// Applies progressive overload or regression based on [succeededExercises].
Future<void> applyProgression({
required Map<String, bool> succeededExercises,
required DateTime lastWorkoutDate,
}) async {
final daysSince = DateTime.now().difference(lastWorkoutDate).inDays;
final hadBreak = daysSince > 7;
for (final entry in succeededExercises.entries) {
final state = await getExerciseState(entry.key);
if (state == null) continue;
if (hadBreak) {
final newWeight = (state.weight - kWeightIncrement).clamp(
0.0,
state.maxWeight,
);
await _db.update(
'exercise_state',
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
where: 'name = ?',
whereArgs: [entry.key],
);
continue;
}
if (entry.value) {
final newStreak = state.successStreak + 1;
final shouldProgress = newStreak >= state.successThreshold;
var newWeight = state.weight;
var newReps = state.reps;
if (shouldProgress) {
if (state.weight >= state.maxWeight) {
newReps = state.reps + 1;
} else {
newWeight = (state.weight + kWeightIncrement).clamp(
0.0,
state.maxWeight,
);
}
}
await _db.update(
'exercise_state',
{
'weight': newWeight,
'reps': newReps,
'success_streak': shouldProgress ? 0 : newStreak,
'fail_streak': 0,
},
where: 'name = ?',
whereArgs: [entry.key],
);
} else {
final newStreak = state.failStreak + 1;
final shouldRegress = newStreak >= state.failThreshold;
final newWeight = shouldRegress
? (state.weight - kWeightIncrement).clamp(0.0, state.maxWeight)
: state.weight;
await _db.update(
'exercise_state',
{
'weight': newWeight,
'fail_streak': shouldRegress ? 0 : newStreak,
'success_streak': 0,
},
where: 'name = ?',
whereArgs: [entry.key],
);
}
}
}
/// Persists a completed session to the workout history table.
Future<void> saveSession({
required String date,
required String workoutType,
required int durationSeconds,
required bool succeeded,
required String json,
}) async {
await _db.insert('workout_history', {
'date': date,
'workout_type': workoutType,
'duration_seconds': durationSeconds,
'succeeded': succeeded ? 1 : 0,
'json': json,
});
unawaited(_backupNow());
}
/// Returns the date of the most recent completed session, or null.
Future<DateTime?> getLastWorkoutDate() async {
final rows = await _db.rawQuery(
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
);
if (rows.isEmpty) return null;
return DateTime.tryParse(rows.first['date']! as String);
}
/// Returns up to [limit] rows from workout history, newest first.
/// Inserts synced sessions this device has no local record of.
///
/// These are the user's OWN completed workouts, already in Firebase/GitHub;
/// a reinstall wipes the local `workout_history` table while the remote copy
/// survives, so the app ends up showing far less history than actually
/// happened. This puts the real records back rather than inventing anything.
///
/// Idempotent: a session is matched on (date, start_time) taken from the
/// payload, so repeated syncs never duplicate a row. Sessions the device
/// already has always win -- the local row is the one the progression
/// engine acted on.
///
/// Returns the number of sessions restored.
Future<int> restoreSyncedSessions(
List<Map<String, dynamic>> payloads,
) async {
if (payloads.isEmpty) return 0;
final existing = await _db.query('workout_history', columns: ['json']);
final seen = <String>{
for (final row in existing) _sessionKey(_decodeJson(row['json'])),
};
var restored = 0;
for (final payload in payloads) {
// Only real sessions: a payload with no exercises is a PC-side
// runnerup/manual record, which belongs in the synced list, not here.
if (payload['exercises'] is! List) continue;
final key = _sessionKey(payload);
if (key.isEmpty || seen.contains(key)) continue;
seen.add(key);
await _db.insert('workout_history', {
'date': payload['date'] as String? ?? '',
'workout_type': payload['workout_type'] as String? ?? '',
'duration_seconds': (payload['duration_seconds'] as num?)?.toInt() ?? 0,
'succeeded': (payload['succeeded'] as bool? ?? false) ? 1 : 0,
'json': jsonEncode(payload),
});
restored++;
}
if (restored > 0) {
debugPrint('WorkoutApp: restored $restored synced session(s) locally.');
unawaited(_backupNow());
}
return restored;
}
/// Identity of a session: its date plus start time, both from the payload.
///
/// `start_time` alone would be enough, but pairing it with the date keeps
/// the key readable in logs and survives a payload missing either field.
static String _sessionKey(Map<String, dynamic>? payload) {
if (payload == null) return '';
final start = payload['start_time'] as String? ?? '';
final date = payload['date'] as String? ?? '';
return start.isEmpty && date.isEmpty ? '' : '$date|$start';
}
static Map<String, dynamic>? _decodeJson(Object? raw) {
if (raw is! String || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : null;
} on FormatException catch (error) {
// A corrupt local row must not block restoring the rest; it simply
// cannot be matched against, so it is treated as "not seen".
debugPrint(
'WorkoutApp: a stored session row is not valid JSON ($error) — '
'treating it as unseen, so a remote copy may be re-imported.',
);
return null;
}
}
/// Returns up to [limit] past sessions, newest first.
Future<List<Map<String, dynamic>>> getWorkoutHistory({
int limit = 60,
@ -622,160 +242,4 @@ class StorageService {
}
// ── Backup / restore ───────────────────────────────────────────────────────
/// Exports all persistent data to external storage as a JSON snapshot.
///
/// Refuses to overwrite a richer existing backup with a freshly-seeded,
/// history-less DB. That combination is never a real state worth saving: it
/// only happens when a restore failed (permission denied, unreadable file)
/// and the DB is sitting at [workoutA]/[workoutB] defaults. The old code
/// exported anyway on the first write after such a seed, replacing the only
/// off-device copy of months of progression with default rows — the step
/// that turned the 2026-08-05 reinstall from recoverable into permanent.
Future<void> _backupNow() async {
final exerciseRows = await _db.query('exercise_state');
final historyRows = await _db.query('workout_history');
final settingsRows = await _db.query('settings');
if (historyRows.isEmpty) {
final existing = await BackupService.instance.readBackup();
final existingHistory =
(existing?['workout_history'] as List?) ?? const [];
if (existingHistory.isNotEmpty) {
debugPrint(
'WorkoutApp: REFUSING to overwrite the backup — this DB has no '
'workout history but the backup holds ${existingHistory.length} '
'session(s). A restore almost certainly failed; keeping the backup '
'so the data stays recoverable.',
);
return;
}
}
await BackupService.instance.export({
'exercise_state': exerciseRows,
'workout_history': historyRows,
'settings': settingsRows,
});
}
/// Whether this database has never recorded a workout on this install.
///
/// "Empty" means no workout history and no [last_workout_type] setting —
/// the state a fresh install or a `pm clear` leaves behind, and the only
/// state in which overwriting local rows wholesale is safe.
///
/// Shared with the Firebase progression restore so both restore paths agree
/// on what "fresh install" means. Deliberately NOT a per-exercise value
/// comparison: [resetExerciseToDefaults] leaves `reps` and `max_weight`
/// alone, so resetting an exercise that already sits at its default reps
/// produces a row byte-identical to a seeded one. Treating that as "fresh"
/// would let a pull silently revert a reset the user just performed.
Future<bool> looksFreshlyInstalled() async {
final hasHistory =
(await _db.rawQuery('SELECT COUNT(*) AS c FROM workout_history'))
.first['c'] as int? ??
0;
if (hasHistory > 0) return false;
return await _getSetting('last_workout_type') == null;
}
/// Whether this install has reconciled its progression with Firebase.
///
/// This — NOT [looksFreshlyInstalled] — is what gates pushing progression
/// up. The freshness test is unusable there because the only production
/// caller (`_finishWorkout`) writes a `workout_history` row and
/// `last_workout_type` *before* it pushes, so the DB has stopped looking
/// fresh by the time the guard runs: the guard could never fire, and a
/// reinstall that connected Firebase late would overwrite every real remote
/// record with factory defaults on its first finished workout.
///
/// It is also wrong for hand edits: `setExerciseWeight` and
/// `resetExerciseToDefaults` write neither history nor `last_workout_type`,
/// so a DB still "looks fresh" after them and the next pull would revert the
/// user's own change.
Future<bool> hasSyncedProgression() async =>
await _getSetting(_progressionSyncedKey) != null;
/// Records that remote progression has been reconciled, so later pushes are
/// allowed and later pulls stop overwriting local state.
Future<void> markProgressionSynced() =>
_setSetting(_progressionSyncedKey, DateTime.now().toIso8601String());
static const _progressionSyncedKey = 'progression_synced_at';
/// When a workout sync last SUCCEEDED, or null if one never has.
///
/// Drives the home screen's "Out of date" card. Nothing persisted a
/// last-sync time before — which is why the app could sit disconnected for
/// days with nothing on screen saying so.
Future<DateTime?> getLastSyncedAt() async {
final raw = await _getSetting(_lastSyncedAtKey);
if (raw == null) return null;
final parsed = DateTime.tryParse(raw);
if (parsed == null) {
// Never silently treat a corrupt timestamp as "never synced": that
// would show the nag card forever with no clue why.
debugPrint(
'WorkoutApp: last_synced_at is not a valid timestamp ($raw) — '
'treating this device as never synced until the next success.',
);
return null;
}
return parsed;
}
/// Records that a sync just succeeded.
Future<void> markSyncedNow([DateTime? at]) => _setSetting(
_lastSyncedAtKey,
(at ?? DateTime.now()).toIso8601String(),
);
static const _lastSyncedAtKey = 'last_synced_at';
/// Restores from backup if the local DB is empty (fresh install).
///
/// "Empty" means no workout history and no [last_workout_type] setting.
Future<void> restoreFromBackupIfNeeded() async {
if (!await looksFreshlyInstalled()) return; // DB has real data
final backup = await BackupService.instance.readBackup();
if (backup == null) {
// readBackup() has already said which case this is. Say what it MEANS:
// the DB is sitting at seeded defaults and that is now the live state.
debugPrint(
'WorkoutApp: no backup restored — the database is at factory defaults. '
'If this is a reinstall, progression state (weights, reps, streaks) '
'has been lost and must be re-entered.',
);
return;
}
await _db.transaction((txn) async {
for (final row in (backup['exercise_state'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'exercise_state',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final row in (backup['workout_history'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'workout_history',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final row in (backup['settings'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'settings',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
});
}
}

View File

@ -0,0 +1,166 @@
// Backup, restore, and the sync marker settings.
//
// An `extension` in a `part` rather than a separate library: Dart cannot
// continue a class body across files, but library-private members (`_db`,
// `_getSetting`) are still reachable from a part, so these methods keep
// working unchanged and callers still say `StorageService.instance.foo()`.
part of 'storage_service.dart';
/// Backup/restore and the last-synced markers.
extension StorageServiceBackup on StorageService {
/// Exports all persistent data to external storage as a JSON snapshot.
///
/// Refuses to overwrite a richer existing backup with a freshly-seeded,
/// history-less DB. That combination is never a real state worth saving: it
/// only happens when a restore failed (permission denied, unreadable file)
/// and the DB is sitting at [workoutA]/[workoutB] defaults. The old code
/// exported anyway on the first write after such a seed, replacing the only
/// off-device copy of months of progression with default rows — the step
/// that turned the 2026-08-05 reinstall from recoverable into permanent.
Future<void> _backupNow() async {
final exerciseRows = await _db.query('exercise_state');
final historyRows = await _db.query('workout_history');
final settingsRows = await _db.query('settings');
if (historyRows.isEmpty) {
final existing = await BackupService.instance.readBackup();
final existingHistory =
(existing?['workout_history'] as List?) ?? const [];
if (existingHistory.isNotEmpty) {
debugPrint(
'WorkoutApp: REFUSING to overwrite the backup — this DB has no '
'workout history but the backup holds ${existingHistory.length} '
'session(s). A restore almost certainly failed; keeping the backup '
'so the data stays recoverable.',
);
return;
}
}
await BackupService.instance.export({
'exercise_state': exerciseRows,
'workout_history': historyRows,
'settings': settingsRows,
});
}
/// Whether this database has never recorded a workout on this install.
///
/// "Empty" means no workout history and no [last_workout_type] setting —
/// the state a fresh install or a `pm clear` leaves behind, and the only
/// state in which overwriting local rows wholesale is safe.
///
/// Shared with the Firebase progression restore so both restore paths agree
/// on what "fresh install" means. Deliberately NOT a per-exercise value
/// comparison: [resetExerciseToDefaults] leaves `reps` and `max_weight`
/// alone, so resetting an exercise that already sits at its default reps
/// produces a row byte-identical to a seeded one. Treating that as "fresh"
/// would let a pull silently revert a reset the user just performed.
Future<bool> looksFreshlyInstalled() async {
final hasHistory =
(await _db.rawQuery('SELECT COUNT(*) AS c FROM workout_history'))
.first['c'] as int? ??
0;
if (hasHistory > 0) return false;
return await _getSetting('last_workout_type') == null;
}
/// Whether this install has reconciled its progression with Firebase.
///
/// This — NOT [looksFreshlyInstalled] — is what gates pushing progression
/// up. The freshness test is unusable there because the only production
/// caller (`_finishWorkout`) writes a `workout_history` row and
/// `last_workout_type` *before* it pushes, so the DB has stopped looking
/// fresh by the time the guard runs: the guard could never fire, and a
/// reinstall that connected Firebase late would overwrite every real remote
/// record with factory defaults on its first finished workout.
///
/// It is also wrong for hand edits: `setExerciseWeight` and
/// `resetExerciseToDefaults` write neither history nor `last_workout_type`,
/// so a DB still "looks fresh" after them and the next pull would revert the
/// user's own change.
Future<bool> hasSyncedProgression() async =>
await _getSetting(_progressionSyncedKey) != null;
/// Records that remote progression has been reconciled, so later pushes are
/// allowed and later pulls stop overwriting local state.
Future<void> markProgressionSynced() =>
_setSetting(_progressionSyncedKey, DateTime.now().toIso8601String());
static const _progressionSyncedKey = 'progression_synced_at';
/// When a workout sync last SUCCEEDED, or null if one never has.
///
/// Drives the home screen's "Out of date" card. Nothing persisted a
/// last-sync time before — which is why the app could sit disconnected for
/// days with nothing on screen saying so.
Future<DateTime?> getLastSyncedAt() async {
final raw = await _getSetting(_lastSyncedAtKey);
if (raw == null) return null;
final parsed = DateTime.tryParse(raw);
if (parsed == null) {
// Never silently treat a corrupt timestamp as "never synced": that
// would show the nag card forever with no clue why.
debugPrint(
'WorkoutApp: last_synced_at is not a valid timestamp ($raw) — '
'treating this device as never synced until the next success.',
);
return null;
}
return parsed;
}
/// Records that a sync just succeeded.
Future<void> markSyncedNow([DateTime? at]) => _setSetting(
_lastSyncedAtKey,
(at ?? DateTime.now()).toIso8601String(),
);
static const _lastSyncedAtKey = 'last_synced_at';
/// Restores from backup if the local DB is empty (fresh install).
///
/// "Empty" means no workout history and no [last_workout_type] setting.
Future<void> restoreFromBackupIfNeeded() async {
if (!await looksFreshlyInstalled()) return; // DB has real data
final backup = await BackupService.instance.readBackup();
if (backup == null) {
// readBackup() has already said which case this is. Say what it MEANS:
// the DB is sitting at seeded defaults and that is now the live state.
debugPrint(
'WorkoutApp: no backup restored — the database is at factory defaults. '
'If this is a reinstall, progression state (weights, reps, streaks) '
'has been lost and must be re-entered.',
);
return;
}
await _db.transaction((txn) async {
for (final row in (backup['exercise_state'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'exercise_state',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final row in (backup['workout_history'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'workout_history',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final row in (backup['settings'] as List? ?? [])
.cast<Map<String, dynamic>>()) {
await txn.insert(
'settings',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
});
}
}

View File

@ -0,0 +1,129 @@
// Per-exercise progression state.
//
// The largest cohesive group: reading and writing exercise_state rows.
// See storage_service_backup.dart for why these are `part` extensions.
part of 'storage_service.dart';
/// Per-exercise progression state.
extension StorageServiceExercises on StorageService {
// ── Exercise state ─────────────────────────────────────────────────────────
/// Returns the progression state for [name], or null if not found.
Future<ExerciseState?> getExerciseState(String name) async {
final rows = await _db.query(
'exercise_state',
where: 'name = ?',
whereArgs: [name],
);
if (rows.isEmpty) return null;
final r = rows.first;
return ExerciseState(
name: r['name']! as String,
weight: r['weight']! as double,
reps: r['reps']! as int,
successStreak: r['success_streak']! as int,
failStreak: r['fail_streak']! as int,
maxWeight: r['max_weight']! as double,
successThreshold: r['success_threshold'] as int? ?? 3,
failThreshold: r['fail_threshold'] as int? ?? 2,
);
}
/// Returns progression states for every exercise across both plans.
Future<List<ExerciseState>> getAllExerciseStates() async {
final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet();
final states = <ExerciseState>[];
for (final name in allNames) {
final s = await getExerciseState(name);
if (s != null) states.add(s);
}
return states;
}
/// Updates the streak thresholds for exercise [name].
Future<void> setExerciseThresholds(
String name, {
required int successThreshold,
required int failThreshold,
}) async {
await _db.update(
'exercise_state',
{
'success_threshold': successThreshold,
'fail_threshold': failThreshold,
},
where: 'name = ?',
whereArgs: [name],
);
}
/// Sets the target reps for [name], resetting streaks.
///
/// Progression can only ever raise reps (by one, and only once an exercise
/// is at its max weight), so without this there is no way to lower a rep
/// target that a defaults re-seed set wrong -- the 2026-08-05 wipe dropped
/// Situp from 31 to the default 30 with no way back short of editing the DB.
Future<void> setExerciseReps(String name, int reps) async {
await _db.update(
'exercise_state',
{'reps': reps, 'success_streak': 0, 'fail_streak': 0},
where: 'name = ?',
whereArgs: [name],
);
unawaited(_backupNow());
}
/// Overwrites [state] wholesale, inserting it when the row is absent.
///
/// Used by the Firebase progression restore, which carries a complete record
/// (weight, reps, both streaks, max weight, both thresholds) and must land it
/// atomically — a field-by-field update would leave a half-restored row if it
/// failed partway. Unlike [setExerciseWeight] this deliberately does NOT
/// reset streaks: the streaks are part of what is being restored.
///
/// Does not trigger a backup write. The caller is restoring INTO a
/// freshly-seeded database, and `_backupNow()` mid-restore is what previously
/// let default rows reach the only off-device copy.
Future<void> replaceExerciseState(ExerciseState state) async {
await _db.insert('exercise_state', {
'name': state.name,
'weight': state.weight,
'reps': state.reps,
'success_streak': state.successStreak,
'fail_streak': state.failStreak,
'max_weight': state.maxWeight,
'success_threshold': state.successThreshold,
'fail_threshold': state.failThreshold,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
/// Sets the working weight for [name], resetting streaks.
Future<void> setExerciseWeight(String name, double weight) async {
await _db.update(
'exercise_state',
{'weight': weight, 'success_streak': 0, 'fail_streak': 0},
where: 'name = ?',
whereArgs: [name],
);
unawaited(_backupNow());
}
/// Returns exercises for [workoutType] with weights/reps from stored state.
Future<List<Exercise>> getCurrentExercises(String workoutType) async {
final template = workoutType == 'A' ? workoutA : workoutB;
final result = <Exercise>[];
for (final ex in template) {
final state = await getExerciseState(ex.name);
if (state == null) {
// Defensive: _seedDefaultsIfNeeded guarantees every template exercise
// has a state row, so this branch is unreachable in practice.
// coverage:ignore-start
result.add(ex);
// coverage:ignore-end
} else {
result.add(ex.copyWith(weight: state.weight, reps: state.reps));
}
}
return result;
}
}

View File

@ -0,0 +1,98 @@
// Schema creation, migration, and seeding.
//
// Runs once at open; nothing else touches these tables' shape.
// See storage_service_backup.dart for why these are `part` extensions.
part of 'storage_service.dart';
/// Schema creation, migration, and seeding.
extension StorageServiceSchema on StorageService {
Future<void> _createSchema(Database db, int version) async {
await db.execute('''
CREATE TABLE exercise_state (
name TEXT PRIMARY KEY,
weight REAL NOT NULL,
reps INTEGER NOT NULL,
success_streak INTEGER NOT NULL DEFAULT 0,
fail_streak INTEGER NOT NULL DEFAULT 0,
max_weight REAL NOT NULL,
success_threshold INTEGER NOT NULL DEFAULT 3,
fail_threshold INTEGER NOT NULL DEFAULT 2
)
''');
await db.execute('''
CREATE TABLE workout_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
workout_type TEXT NOT NULL,
duration_seconds INTEGER NOT NULL,
succeeded INTEGER NOT NULL,
json TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE active_session (
id INTEGER PRIMARY KEY CHECK (id = 1),
json TEXT NOT NULL
)
''');
}
Future<void> _migrateSchema(
Database db,
int oldVersion,
int newVersion,
) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE exercise_state '
'ADD COLUMN success_threshold INTEGER NOT NULL DEFAULT 3',
);
await db.execute(
'ALTER TABLE exercise_state '
'ADD COLUMN fail_threshold INTEGER NOT NULL DEFAULT 2',
);
}
if (oldVersion < 3) {
await db.execute(
'CREATE TABLE IF NOT EXISTS settings '
'(key TEXT PRIMARY KEY, value TEXT NOT NULL)',
);
await db.execute(
'CREATE TABLE IF NOT EXISTS active_session '
'(id INTEGER PRIMARY KEY CHECK (id = 1), json TEXT NOT NULL)',
);
}
}
Future<void> _seedDefaultsIfNeeded() async {
for (final ex in [...workoutA, ...workoutB]) {
final rows = await _db.query(
'exercise_state',
where: 'name = ?',
whereArgs: [ex.name],
);
if (rows.isEmpty) {
await _db.insert('exercise_state', {
'name': ex.name,
'weight': ex.weight,
'reps': ex.reps,
'success_streak': 0,
'fail_streak': 0,
'max_weight': ex.maxWeight,
'success_threshold': 3,
'fail_threshold': 2,
});
}
}
}
// ── Settings ───────────────────────────────────────────────────────────────
}

View File

@ -0,0 +1,186 @@
// Progression application and session recording.
//
// Split from storage_service_exercises.dart: that file reads and writes a
// single exercise's row, this one advances progression and records whole
// sessions. See storage_service_backup.dart for why these are `part`
// extensions.
part of 'storage_service.dart';
/// Progression application and session recording.
extension StorageServiceSessions on StorageService {
/// Applies progressive overload or regression based on [succeededExercises].
Future<void> applyProgression({
required Map<String, bool> succeededExercises,
required DateTime lastWorkoutDate,
}) async {
final daysSince = DateTime.now().difference(lastWorkoutDate).inDays;
final hadBreak = daysSince > 7;
for (final entry in succeededExercises.entries) {
final state = await getExerciseState(entry.key);
if (state == null) continue;
if (hadBreak) {
final newWeight = (state.weight - kWeightIncrement).clamp(
0.0,
state.maxWeight,
);
await _db.update(
'exercise_state',
{'weight': newWeight, 'success_streak': 0, 'fail_streak': 0},
where: 'name = ?',
whereArgs: [entry.key],
);
continue;
}
if (entry.value) {
final newStreak = state.successStreak + 1;
final shouldProgress = newStreak >= state.successThreshold;
var newWeight = state.weight;
var newReps = state.reps;
if (shouldProgress) {
if (state.weight >= state.maxWeight) {
newReps = state.reps + 1;
} else {
newWeight = (state.weight + kWeightIncrement).clamp(
0.0,
state.maxWeight,
);
}
}
await _db.update(
'exercise_state',
{
'weight': newWeight,
'reps': newReps,
'success_streak': shouldProgress ? 0 : newStreak,
'fail_streak': 0,
},
where: 'name = ?',
whereArgs: [entry.key],
);
} else {
final newStreak = state.failStreak + 1;
final shouldRegress = newStreak >= state.failThreshold;
final newWeight = shouldRegress
? (state.weight - kWeightIncrement).clamp(0.0, state.maxWeight)
: state.weight;
await _db.update(
'exercise_state',
{
'weight': newWeight,
'fail_streak': shouldRegress ? 0 : newStreak,
'success_streak': 0,
},
where: 'name = ?',
whereArgs: [entry.key],
);
}
}
}
/// Persists a completed session to the workout history table.
Future<void> saveSession({
required String date,
required String workoutType,
required int durationSeconds,
required bool succeeded,
required String json,
}) async {
await _db.insert('workout_history', {
'date': date,
'workout_type': workoutType,
'duration_seconds': durationSeconds,
'succeeded': succeeded ? 1 : 0,
'json': json,
});
unawaited(_backupNow());
}
/// Returns the date of the most recent completed session, or null.
Future<DateTime?> getLastWorkoutDate() async {
final rows = await _db.rawQuery(
'SELECT date FROM workout_history ORDER BY date DESC LIMIT 1',
);
if (rows.isEmpty) return null;
return DateTime.tryParse(rows.first['date']! as String);
}
/// Returns up to [limit] rows from workout history, newest first.
/// Inserts synced sessions this device has no local record of.
///
/// These are the user's OWN completed workouts, already in Firebase/GitHub;
/// a reinstall wipes the local `workout_history` table while the remote copy
/// survives, so the app ends up showing far less history than actually
/// happened. This puts the real records back rather than inventing anything.
///
/// Idempotent: a session is matched on (date, start_time) taken from the
/// payload, so repeated syncs never duplicate a row. Sessions the device
/// already has always win -- the local row is the one the progression
/// engine acted on.
///
/// Returns the number of sessions restored.
Future<int> restoreSyncedSessions(
List<Map<String, dynamic>> payloads,
) async {
if (payloads.isEmpty) return 0;
final existing = await _db.query('workout_history', columns: ['json']);
final seen = <String>{
for (final row in existing) _sessionKey(_decodeJson(row['json'])),
};
var restored = 0;
for (final payload in payloads) {
// Only real sessions: a payload with no exercises is a PC-side
// runnerup/manual record, which belongs in the synced list, not here.
if (payload['exercises'] is! List) continue;
final key = _sessionKey(payload);
if (key.isEmpty || seen.contains(key)) continue;
seen.add(key);
await _db.insert('workout_history', {
'date': payload['date'] as String? ?? '',
'workout_type': payload['workout_type'] as String? ?? '',
'duration_seconds': (payload['duration_seconds'] as num?)?.toInt() ?? 0,
'succeeded': (payload['succeeded'] as bool? ?? false) ? 1 : 0,
'json': jsonEncode(payload),
});
restored++;
}
if (restored > 0) {
debugPrint('WorkoutApp: restored $restored synced session(s) locally.');
unawaited(_backupNow());
}
return restored;
}
/// Identity of a session: its date plus start time, both from the payload.
///
/// `start_time` alone would be enough, but pairing it with the date keeps
/// the key readable in logs and survives a payload missing either field.
static String _sessionKey(Map<String, dynamic>? payload) {
if (payload == null) return '';
final start = payload['start_time'] as String? ?? '';
final date = payload['date'] as String? ?? '';
return start.isEmpty && date.isEmpty ? '' : '$date|$start';
}
static Map<String, dynamic>? _decodeJson(Object? raw) {
if (raw is! String || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : null;
} on FormatException catch (error) {
// A corrupt local row must not block restoring the rest; it simply
// cannot be matched against, so it is treated as "not seen".
debugPrint(
'WorkoutApp: a stored session row is not valid JSON ($error) — '
'treating it as unseen, so a remote copy may be re-imported.',
);
return null;
}
}
}