From d418994f6fd1815ae47602cbccc8f7b21a072605 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 21:12:08 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- scripts/check_silent_failures_dart.py | 8 +- .../lib/services/storage_service.dart | 546 +----------------- .../lib/services/storage_service_backup.dart | 166 ++++++ .../services/storage_service_exercises.dart | 129 +++++ .../lib/services/storage_service_schema.dart | 98 ++++ .../services/storage_service_sessions.dart | 186 ++++++ 6 files changed, 590 insertions(+), 543 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/services/storage_service_backup.dart create mode 100644 stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart create mode 100644 stronglift_replacement/workout_app/lib/services/storage_service_schema.dart create mode 100644 stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart diff --git a/scripts/check_silent_failures_dart.py b/scripts/check_silent_failures_dart.py index 8d2ad3b..3df9fec 100755 --- a/scripts/check_silent_failures_dart.py +++ b/scripts/check_silent_failures_dart.py @@ -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*\}") diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index b7b1523..bab27fd 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -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 _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 _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 _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 _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 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> getAllExerciseStates() async { - final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet(); - final states = []; - 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 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 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 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 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> getCurrentExercises(String workoutType) async { - final template = workoutType == 'A' ? workoutA : workoutB; - final result = []; - 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 applyProgression({ - required Map 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 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 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 restoreSyncedSessions( - List> payloads, - ) async { - if (payloads.isEmpty) return 0; - final existing = await _db.query('workout_history', columns: ['json']); - final seen = { - 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? 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? _decodeJson(Object? raw) { - if (raw is! String || raw.isEmpty) return null; - try { - final decoded = jsonDecode(raw); - return decoded is Map ? 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>> 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 _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 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 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 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 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 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 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>()) { - await txn.insert( - 'exercise_state', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - for (final row in (backup['workout_history'] as List? ?? []) - .cast>()) { - await txn.insert( - 'workout_history', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - for (final row in (backup['settings'] as List? ?? []) - .cast>()) { - await txn.insert( - 'settings', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - }); - } } diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart new file mode 100644 index 0000000..4c6c44c --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart @@ -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 _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 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 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 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 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 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 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>()) { + await txn.insert( + 'exercise_state', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + for (final row in (backup['workout_history'] as List? ?? []) + .cast>()) { + await txn.insert( + 'workout_history', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + for (final row in (backup['settings'] as List? ?? []) + .cast>()) { + await txn.insert( + 'settings', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + }); + } +} diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart new file mode 100644 index 0000000..bbe0009 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart @@ -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 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> getAllExerciseStates() async { + final allNames = [...workoutA, ...workoutB].map((e) => e.name).toSet(); + final states = []; + 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 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 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 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 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> getCurrentExercises(String workoutType) async { + final template = workoutType == 'A' ? workoutA : workoutB; + final result = []; + 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; + } +} diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart new file mode 100644 index 0000000..45383d9 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart @@ -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 _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 _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 _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 ─────────────────────────────────────────────────────────────── + +} diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart b/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart new file mode 100644 index 0000000..eaae978 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart @@ -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 applyProgression({ + required Map 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 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 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 restoreSyncedSessions( + List> payloads, + ) async { + if (payloads.isEmpty) return 0; + final existing = await _db.query('workout_history', columns: ['json']); + final seen = { + 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? 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? _decodeJson(Object? raw) { + if (raw is! String || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? 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; + } + } +}