diff --git a/docs/superpowers/plans/2026-08-08-sqlite-migration-and-coach-sql-tool.md b/docs/superpowers/plans/2026-08-08-sqlite-migration-and-coach-sql-tool.md new file mode 100644 index 0000000..e93a790 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-sqlite-migration-and-coach-sql-tool.md @@ -0,0 +1,2413 @@ +# Hive → SQLite Migration + Coach SQL Query Tool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Hive with SQLite (`sqflite`) as RepForge's persistence backend via a safe, one-time, reversible migration, then add a `run_sql_query` tool to the AI Coach that queries the live database directly. + +**Architecture:** A new `SqliteStorageService implements IStorageService` sits alongside the existing Hive-backed `StorageService`. A `StorageMigrationService` copies data from one to the other exactly once, gated by a flag stored in the Hive settings box, with no deletion of Hive data and automatic fallback to Hive on any migration failure. `main.dart`'s composition root resolves which backend to hand to the rest of the app before `runApp()`. The Coach's new `run_sql_query` tool opens a dedicated **read-only** connection to the same SQLite file and runs model-submitted `SELECT` statements against live data. + +**Tech Stack:** Flutter/Dart, `sqflite` (runtime), `sqflite_common_ffi` (dev/test only), existing `hive`/`hive_flutter` (kept, not removed), `google_generative_ai` (existing Coach tool-calling), `flutter_test`. + +## Global Constraints + +- No changes to any `IStorageService` method signature (spec §2 non-goal). Additions to concrete classes are fine. +- No changes to any manager, `WorkoutProvider`, or screen — all depend on `IStorageService`/`MockStorageService`, never a concrete backend. +- Hive boxes are never deleted at any point in this plan (spec §6.6). +- All new SQLite code lives under `lib/services/` (storage) and `lib/services/ai/` (SQL tool), matching existing structure. +- Follow the schema exactly as specified in `docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md` §4. + +--- + +### Task 1: Add SQLite dependencies + +**Files:** +- Modify: `workout-logger/pubspec.yaml` + +**Interfaces:** +- Produces: `sqflite` and `sqflite_common_ffi` packages available for import in later tasks. + +- [ ] **Step 1: Add dependencies** + +In `workout-logger/pubspec.yaml`, add to the `dependencies:` section (after the `hive_flutter` line): + +```yaml + # SQLite persistence (replacing Hive) + sqflite: ^2.4.2 +``` + +Add to the `dev_dependencies:` section (after `build_runner`): + +```yaml + # sqflite testing on the Dart VM (flutter test has no platform binding) + sqflite_common_ffi: ^2.3.4+4 +``` + +- [ ] **Step 2: Install** + +Run: `cd workout-logger && flutter pub get` +Expected: resolves successfully, `pubspec.lock` updated with `sqflite` and `sqflite_common_ffi`. + +- [ ] **Step 3: Commit** + +```bash +git add workout-logger/pubspec.yaml workout-logger/pubspec.lock +git commit -m "chore: add sqflite dependencies for SQLite storage migration" +``` + +--- + +### Task 2: `SqliteStorageService` — schema + workout sessions + +**Files:** +- Create: `workout-logger/lib/services/sqlite_storage_service.dart` +- Test: `workout-logger/test/sqlite_storage_service_test.dart` + +**Interfaces:** +- Consumes: `IStorageService` (`lib/services/interfaces/storage_service_interface.dart`), models from `lib/models/models.dart`, `ExerciseDatabase`/`MuscleGroups` from `lib/data/exercise_database.dart`. +- Produces: `class SqliteStorageService implements IStorageService` with: + - `Future init()` + - `String get databasePath` (exposes the open DB's file path for `SqlQueryService`, Task 10) + - `SqliteStorageService({String? databasePathOverride})` constructor (override used by tests for `inMemoryDatabasePath`) + - Full workout-session CRUD this task implements: `saveWorkoutSession`, `getAllWorkoutSessions`, `getWorkoutSession`, `deleteWorkoutSession`, `getSessionsForExercise`, `getSessionsInDateRange` + - Remaining `IStorageService` methods stubbed with `throw UnimplementedError()` (filled in by Tasks 3–6) + +- [ ] **Step 1: Write the failing test** + +Create `workout-logger/test/sqlite_storage_service_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; + +void main() { + late SqliteStorageService storage; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + storage = SqliteStorageService(databasePathOverride: inMemoryDatabasePath); + await storage.init(); + }); + + group('SqliteStorageService — init', () { + test('seeds default muscle groups', () async { + final groups = await storage.getAllMuscleGroups(); + expect(groups, isNotEmpty); + expect(groups.any((g) => g.name == 'Chest'), isTrue); + }); + }); + + group('SqliteStorageService — workout sessions', () { + test('saveWorkoutSession + getWorkoutSession round-trips nested sets', () async { + final session = WorkoutSession( + id: 's1', + date: DateTime(2026, 7, 10), + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: 60, reps: 8), + WorkoutSet(weight: 65, reps: 6, isDropset: true, drops: [ + DropsetEntry(weight: 50, reps: 10), + ]), + ], + ), + ], + ); + + await storage.saveWorkoutSession(session); + final fetched = await storage.getWorkoutSession('s1'); + + expect(fetched, isNotNull); + expect(fetched!.duration, 45); + expect(fetched.exercises.single.sets.length, 2); + expect(fetched.exercises.single.sets.first.weight, 60); + expect(fetched.exercises.single.sets[1].isDropset, isTrue); + expect(fetched.exercises.single.sets[1].drops!.single.weight, 50); + }); + + test('saveWorkoutSession overwrites previous sets on re-save', () async { + final session = WorkoutSession( + id: 's2', + date: DateTime(2026, 7, 1), + duration: 30, + exercises: [ + ExerciseLog(exerciseId: 'squat', sets: [WorkoutSet(weight: 100, reps: 5)]), + ], + ); + await storage.saveWorkoutSession(session); + + final updated = session.copyWith( + exercises: [ + ExerciseLog(exerciseId: 'squat', sets: [WorkoutSet(weight: 110, reps: 3)]), + ], + ); + await storage.saveWorkoutSession(updated); + + final fetched = await storage.getWorkoutSession('s2'); + expect(fetched!.exercises.single.sets.length, 1); + expect(fetched.exercises.single.sets.first.weight, 110); + }); + + test('deleteWorkoutSession removes the session', () async { + final session = WorkoutSession( + id: 's3', + date: DateTime.now(), + duration: 20, + exercises: [ExerciseLog(exerciseId: 'row', sets: [WorkoutSet(weight: 40, reps: 10)])], + ); + await storage.saveWorkoutSession(session); + await storage.deleteWorkoutSession('s3'); + expect(await storage.getWorkoutSession('s3'), isNull); + }); + + test('getAllWorkoutSessions returns most-recent first', () async { + await storage.saveWorkoutSession( + WorkoutSession(id: 'old', date: DateTime(2026, 1, 1), duration: 10, exercises: []), + ); + await storage.saveWorkoutSession( + WorkoutSession(id: 'new', date: DateTime(2026, 6, 1), duration: 10, exercises: []), + ); + final all = await storage.getAllWorkoutSessions(); + expect(all.first.id, 'new'); + }); + + test('getSessionsInDateRange filters by date', () async { + await storage.saveWorkoutSession( + WorkoutSession(id: 'a', date: DateTime(2026, 1, 1), duration: 10, exercises: []), + ); + await storage.saveWorkoutSession( + WorkoutSession(id: 'b', date: DateTime(2026, 6, 1), duration: 10, exercises: []), + ); + final result = await storage.getSessionsInDateRange(DateTime(2026, 5, 1), DateTime(2026, 7, 1)); + expect(result.map((s) => s.id), ['b']); + }); + + test('getSessionsForExercise filters by exercise id', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'c1', date: DateTime.now(), duration: 10, + exercises: [ExerciseLog(exerciseId: 'deadlift', sets: [WorkoutSet(weight: 120, reps: 5)])], + )); + await storage.saveWorkoutSession(WorkoutSession( + id: 'c2', date: DateTime.now(), duration: 10, + exercises: [ExerciseLog(exerciseId: 'squat', sets: [WorkoutSet(weight: 100, reps: 5)])], + )); + final result = await storage.getSessionsForExercise('deadlift'); + expect(result.map((s) => s.id), ['c1']); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: FAIL — `lib/services/sqlite_storage_service.dart` does not exist. + +- [ ] **Step 3: Implement schema + sessions CRUD** + +Create `workout-logger/lib/services/sqlite_storage_service.dart`: + +```dart +// SQLite-backed implementation of IStorageService — replaces Hive as the +// persistence backend. See docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md +// for the schema and migration design this implements. + +import 'dart:convert'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sqflite/sqflite.dart'; +import '../models/models.dart'; +import '../data/exercise_database.dart'; +import 'interfaces/storage_service_interface.dart'; + +class SqliteStorageService implements IStorageService { + SqliteStorageService({String? databasePathOverride}) + : _databasePathOverride = databasePathOverride; + + static const String _dbName = 'repforge.db'; + static const int _dbVersion = 1; + + static const List _schemaStatements = [ + '''CREATE TABLE exercises ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + is_custom INTEGER NOT NULL DEFAULT 0, + available_handles TEXT + )''', + '''CREATE TABLE muscle_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + growth_rate REAL NOT NULL DEFAULT 0, + last_updated TEXT NOT NULL + )''', + '''CREATE TABLE exercise_muscle_activations ( + exercise_id TEXT NOT NULL, + muscle_group_id TEXT NOT NULL, + activation_percentage INTEGER NOT NULL + )''', + '''CREATE TABLE routines ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL + )''', + '''CREATE TABLE routine_exercises ( + routine_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + position INTEGER NOT NULL + )''', + '''CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + routine_id TEXT, + duration_min INTEGER NOT NULL, + notes TEXT, + hc_synced_at TEXT + )''', + '''CREATE TABLE exercise_logs ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + notes TEXT, + handle TEXT + )''', + '''CREATE TABLE sets ( + id TEXT PRIMARY KEY, + exercise_log_id TEXT NOT NULL, + weight REAL NOT NULL, + reps INTEGER NOT NULL, + is_dropset INTEGER NOT NULL DEFAULT 0, + drops_json TEXT, + time_taken INTEGER, + timestamp TEXT NOT NULL, + assist_weight REAL, + extra_weight REAL, + handle TEXT + )''', + '''CREATE TABLE targets ( + id TEXT PRIMARY KEY, + exercise_id TEXT NOT NULL, + target_type TEXT NOT NULL, + target_value REAL NOT NULL, + current_value REAL NOT NULL DEFAULT 0, + estimated_completion_date TEXT, + created_at TEXT NOT NULL, + is_completed INTEGER NOT NULL DEFAULT 0 + )''', + '''CREATE TABLE personal_records ( + exercise_id TEXT PRIMARY KEY, + best_weight REAL NOT NULL, + best_reps INTEGER NOT NULL, + best_volume REAL NOT NULL, + achieved_at TEXT NOT NULL + )''', + '''CREATE TABLE training_programs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + total_weeks INTEGER NOT NULL, + author TEXT, + is_imported INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + phases_json TEXT NOT NULL, + weeks_json TEXT NOT NULL + )''', + '''CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'coach', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + messages_json TEXT NOT NULL + )''', + '''CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT + )''', + 'CREATE INDEX idx_sets_exercise_log ON sets(exercise_log_id)', + 'CREATE INDEX idx_exercise_logs_session ON exercise_logs(session_id)', + 'CREATE INDEX idx_exercise_logs_exercise ON exercise_logs(exercise_id)', + 'CREATE INDEX idx_sessions_date ON sessions(date)', + ]; + + final String? _databasePathOverride; + late Database _db; + bool _initialized = false; + + String _appVersion = const String.fromEnvironment( + 'APP_VERSION', + defaultValue: 'unknown', + ); + + /// File path of the open database — used by SqlQueryService to open a + /// separate read-only connection for the coach's SQL tool. + String get databasePath => _db.path; + + @override + Future init() async { + if (_initialized) return; + + try { + final packageInfo = await PackageInfo.fromPlatform(); + final version = packageInfo.version; + final buildNumber = packageInfo.buildNumber; + _appVersion = buildNumber.isNotEmpty ? '$version+$buildNumber' : version; + } catch (_) { + // Keep build-time fallback in environments without platform metadata. + } + + final dbPath = _databasePathOverride ?? '${await getDatabasesPath()}/$_dbName'; + _db = await openDatabase( + dbPath, + version: _dbVersion, + onCreate: (db, version) async { + for (final statement in _schemaStatements) { + await db.execute(statement); + } + }, + ); + + final count = Sqflite.firstIntValue( + await _db.rawQuery('SELECT COUNT(*) FROM muscle_groups'), + ) ?? + 0; + if (count == 0) { + await _seedDefaultMuscleGroups(); + } + + _initialized = true; + } + + Future _seedDefaultMuscleGroups() async { + final batch = _db.batch(); + for (final mg in MuscleGroups.getAll()) { + batch.insert('muscle_groups', { + 'id': mg.id, + 'name': mg.name, + 'growth_rate': mg.growthRate, + 'last_updated': mg.lastUpdated.toIso8601String(), + }); + } + await batch.commit(noResult: true); + } + + // ==================== WORKOUT SESSIONS ==================== + + @override + Future saveWorkoutSession(WorkoutSession session) async { + await _db.transaction((txn) async { + final oldLogs = await txn.query( + 'exercise_logs', + columns: ['id'], + where: 'session_id = ?', + whereArgs: [session.id], + ); + for (final row in oldLogs) { + await txn.delete('sets', where: 'exercise_log_id = ?', whereArgs: [row['id']]); + } + await txn.delete('exercise_logs', where: 'session_id = ?', whereArgs: [session.id]); + await txn.delete('sessions', where: 'id = ?', whereArgs: [session.id]); + + await txn.insert('sessions', { + 'id': session.id, + 'date': session.date.toIso8601String(), + 'routine_id': session.routineId, + 'duration_min': session.duration, + 'notes': session.notes, + 'hc_synced_at': session.hcSyncedAt?.toIso8601String(), + }); + + for (var i = 0; i < session.exercises.length; i++) { + final log = session.exercises[i]; + final logId = '${session.id}_$i'; + await txn.insert('exercise_logs', { + 'id': logId, + 'session_id': session.id, + 'exercise_id': log.exerciseId, + 'notes': log.notes, + 'handle': log.handle, + }); + for (var j = 0; j < log.sets.length; j++) { + final set = log.sets[j]; + await txn.insert('sets', { + 'id': '${logId}_$j', + 'exercise_log_id': logId, + 'weight': set.weight, + 'reps': set.reps, + 'is_dropset': set.isDropset ? 1 : 0, + 'drops_json': set.drops == null + ? null + : jsonEncode(set.drops!.map((d) => d.toJson()).toList()), + 'time_taken': set.timeTaken, + 'timestamp': set.timestamp.toIso8601String(), + 'assist_weight': set.assistWeight, + 'extra_weight': set.extraWeight, + 'handle': set.handle, + }); + } + } + }); + } + + Future> _loadSessions({String? where, List? whereArgs}) async { + final sessionRows = await _db.query('sessions', where: where, whereArgs: whereArgs); + final sessions = []; + for (final row in sessionRows) { + final sessionId = row['id'] as String; + final logRows = await _db.query( + 'exercise_logs', + where: 'session_id = ?', + whereArgs: [sessionId], + orderBy: 'id ASC', + ); + final exerciseLogs = []; + for (final logRow in logRows) { + final logId = logRow['id'] as String; + final setRows = await _db.query( + 'sets', + where: 'exercise_log_id = ?', + whereArgs: [logId], + orderBy: 'id ASC', + ); + final sets = setRows + .map((s) => WorkoutSet( + weight: (s['weight'] as num).toDouble(), + reps: s['reps'] as int, + isDropset: (s['is_dropset'] as int) == 1, + drops: s['drops_json'] == null + ? null + : (jsonDecode(s['drops_json'] as String) as List) + .map((d) => DropsetEntry.fromJson(d as Map)) + .toList(), + timeTaken: s['time_taken'] as int?, + timestamp: DateTime.parse(s['timestamp'] as String), + assistWeight: (s['assist_weight'] as num?)?.toDouble(), + extraWeight: (s['extra_weight'] as num?)?.toDouble(), + handle: s['handle'] as String?, + )) + .toList(); + exerciseLogs.add(ExerciseLog( + exerciseId: logRow['exercise_id'] as String, + sets: sets, + notes: logRow['notes'] as String?, + handle: logRow['handle'] as String?, + )); + } + sessions.add(WorkoutSession( + id: sessionId, + date: DateTime.parse(row['date'] as String), + routineId: row['routine_id'] as String?, + exercises: exerciseLogs, + duration: row['duration_min'] as int, + notes: row['notes'] as String?, + hcSyncedAt: row['hc_synced_at'] == null + ? null + : DateTime.parse(row['hc_synced_at'] as String), + )); + } + sessions.sort((a, b) => b.date.compareTo(a.date)); + return sessions; + } + + @override + Future> getAllWorkoutSessions() => _loadSessions(); + + @override + Future getWorkoutSession(String id) async { + final result = await _loadSessions(where: 'id = ?', whereArgs: [id]); + return result.isEmpty ? null : result.first; + } + + @override + Future deleteWorkoutSession(String id) async { + await _db.transaction((txn) async { + final logRows = await txn.query( + 'exercise_logs', + columns: ['id'], + where: 'session_id = ?', + whereArgs: [id], + ); + for (final row in logRows) { + await txn.delete('sets', where: 'exercise_log_id = ?', whereArgs: [row['id']]); + } + await txn.delete('exercise_logs', where: 'session_id = ?', whereArgs: [id]); + await txn.delete('sessions', where: 'id = ?', whereArgs: [id]); + }); + } + + @override + Future> getSessionsForExercise(String exerciseId) async { + final all = await getAllWorkoutSessions(); + return all.where((s) => s.exercises.any((e) => e.exerciseId == exerciseId)).toList(); + } + + @override + Future> getSessionsInDateRange(DateTime start, DateTime end) async { + final all = await getAllWorkoutSessions(); + final lo = start.isAfter(end) ? end : start; + final hi = start.isAfter(end) ? start : end; + return all.where((s) => !s.date.isBefore(lo) && !s.date.isAfter(hi)).toList(); + } + + // ==================== ROUTINES (Task 3) ==================== + + @override + Future saveRoutine(Routine routine) => throw UnimplementedError(); + @override + Future> getAllRoutines() => throw UnimplementedError(); + @override + Future getRoutine(String id) => throw UnimplementedError(); + @override + Future deleteRoutine(String id) => throw UnimplementedError(); + + // ==================== TARGETS (Task 3) ==================== + + @override + Future saveTarget(Target target) => throw UnimplementedError(); + @override + Future> getAllTargets() => throw UnimplementedError(); + @override + Future getTarget(String id) => throw UnimplementedError(); + @override + Future deleteTarget(String id) => throw UnimplementedError(); + @override + Future> getTargetsForExercise(String exerciseId) => throw UnimplementedError(); + + // ==================== MUSCLE GROUPS / EXERCISES (Task 4) ==================== + + @override + Future updateMuscleGroupGrowthRate(String muscleGroupId, double rate) => + throw UnimplementedError(); + @override + Future> getAllMuscleGroups() => throw UnimplementedError(); + @override + Future getMuscleGroup(String id) => throw UnimplementedError(); + @override + Future saveCustomExercise(Exercise exercise) => throw UnimplementedError(); + @override + Future> getCustomExercises() => throw UnimplementedError(); + @override + Future deleteCustomExercise(String id) => throw UnimplementedError(); + @override + Future> getAllExercises() => throw UnimplementedError(); + @override + Future getExercise(String id) => throw UnimplementedError(); + + // ==================== SETTINGS / PROGRAMS / PRs / CONVERSATIONS (Task 5) ==================== + + @override + Future saveSetting(String key, String value) => throw UnimplementedError(); + @override + Future getSetting(String key) => throw UnimplementedError(); + @override + Future saveTrainingProgram(TrainingProgram program) => throw UnimplementedError(); + @override + Future> getAllTrainingPrograms() => throw UnimplementedError(); + @override + Future getTrainingProgram(String id) => throw UnimplementedError(); + @override + Future deleteTrainingProgram(String id) => throw UnimplementedError(); + @override + Future savePersonalRecord(PersonalRecord record) => throw UnimplementedError(); + @override + Future getPersonalRecord(String exerciseId) => throw UnimplementedError(); + @override + Future> getAllPersonalRecords() => throw UnimplementedError(); + @override + Future saveConversation(Conversation conversation) => throw UnimplementedError(); + @override + Future> getAllConversations() => throw UnimplementedError(); + @override + Future getConversation(String id) => throw UnimplementedError(); + @override + Future deleteConversation(String id) => throw UnimplementedError(); + @override + Future> getQuickStats() => throw UnimplementedError(); + + // ==================== EXPORT / IMPORT (Task 6) ==================== + + @override + Future exportAllData() => throw UnimplementedError(); + @override + Future importData(String jsonData) => throw UnimplementedError(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: PASS (all 7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/sqlite_storage_service.dart workout-logger/test/sqlite_storage_service_test.dart +git commit -m "feat: add SqliteStorageService with schema and workout session CRUD" +``` + +--- + +### Task 3: `SqliteStorageService` — routines + targets + +**Files:** +- Modify: `workout-logger/lib/services/sqlite_storage_service.dart` +- Modify: `workout-logger/test/sqlite_storage_service_test.dart` + +**Interfaces:** +- Consumes: schema from Task 2 (`routines`, `routine_exercises`, `targets` tables). +- Produces: working `saveRoutine`, `getAllRoutines`, `getRoutine`, `deleteRoutine`, `saveTarget`, `getAllTargets`, `getTarget`, `deleteTarget`, `getTargetsForExercise`. + +- [ ] **Step 1: Write the failing tests** + +Append to `workout-logger/test/sqlite_storage_service_test.dart` (inside `main()`, alongside the existing groups): + +```dart + group('SqliteStorageService — routines', () { + test('saveRoutine + getRoutine round-trips ordered exercise ids', () async { + await storage.saveRoutine(Routine( + id: 'r1', + name: 'Push Day', + exerciseIds: ['bench_press', 'shoulder_press', 'triceps_pushdown'], + )); + final fetched = await storage.getRoutine('r1'); + expect(fetched!.name, 'Push Day'); + expect(fetched.exerciseIds, ['bench_press', 'shoulder_press', 'triceps_pushdown']); + }); + + test('saveRoutine overwrites exercise order on re-save', () async { + await storage.saveRoutine(Routine(id: 'r2', name: 'Pull Day', exerciseIds: ['a', 'b'])); + await storage.saveRoutine(Routine(id: 'r2', name: 'Pull Day', exerciseIds: ['b', 'a', 'c'])); + final fetched = await storage.getRoutine('r2'); + expect(fetched!.exerciseIds, ['b', 'a', 'c']); + }); + + test('deleteRoutine removes it', () async { + await storage.saveRoutine(Routine(id: 'r3', name: 'Legs', exerciseIds: ['squat'])); + await storage.deleteRoutine('r3'); + expect(await storage.getRoutine('r3'), isNull); + }); + + test('getAllRoutines returns all saved routines', () async { + await storage.saveRoutine(Routine(id: 'r4', name: 'A', exerciseIds: [])); + await storage.saveRoutine(Routine(id: 'r5', name: 'B', exerciseIds: [])); + final all = await storage.getAllRoutines(); + expect(all.map((r) => r.id), containsAll(['r4', 'r5'])); + }); + }); + + group('SqliteStorageService — targets', () { + test('saveTarget + getTarget round-trips', () async { + await storage.saveTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100, + currentValue: 70, + )); + final fetched = await storage.getTarget('t1'); + expect(fetched!.targetValue, 100); + expect(fetched.currentValue, 70); + }); + + test('deleteTarget removes it', () async { + await storage.saveTarget(Target(id: 't2', exerciseId: 'squat', targetType: 'weight', targetValue: 150)); + await storage.deleteTarget('t2'); + expect(await storage.getTarget('t2'), isNull); + }); + + test('getTargetsForExercise filters by exercise id', () async { + await storage.saveTarget(Target(id: 't3', exerciseId: 'squat', targetType: 'weight', targetValue: 150)); + await storage.saveTarget(Target(id: 't4', exerciseId: 'deadlift', targetType: 'weight', targetValue: 180)); + final result = await storage.getTargetsForExercise('squat'); + expect(result.map((t) => t.id), ['t3']); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: FAIL with `UnimplementedError` on the routine/target tests. + +- [ ] **Step 3: Implement routines + targets** + +In `workout-logger/lib/services/sqlite_storage_service.dart`, replace the `// ==================== ROUTINES (Task 3) ====================` and `// ==================== TARGETS (Task 3) ====================` sections with: + +```dart + // ==================== ROUTINES ==================== + + @override + Future saveRoutine(Routine routine) async { + await _db.transaction((txn) async { + await txn.delete('routine_exercises', where: 'routine_id = ?', whereArgs: [routine.id]); + await txn.insert( + 'routines', + { + 'id': routine.id, + 'name': routine.name, + 'created_at': routine.createdAt.toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (var i = 0; i < routine.exerciseIds.length; i++) { + await txn.insert('routine_exercises', { + 'routine_id': routine.id, + 'exercise_id': routine.exerciseIds[i], + 'position': i, + }); + } + }); + } + + Future _loadRoutineRow(Map row) async { + final exRows = await _db.query( + 'routine_exercises', + where: 'routine_id = ?', + whereArgs: [row['id']], + orderBy: 'position ASC', + ); + return Routine( + id: row['id'] as String, + name: row['name'] as String, + exerciseIds: exRows.map((r) => r['exercise_id'] as String).toList(), + createdAt: DateTime.parse(row['created_at'] as String), + ); + } + + @override + Future> getAllRoutines() async { + final rows = await _db.query('routines'); + final result = []; + for (final row in rows) { + result.add(await _loadRoutineRow(row)); + } + return result; + } + + @override + Future getRoutine(String id) async { + final rows = await _db.query('routines', where: 'id = ?', whereArgs: [id]); + if (rows.isEmpty) return null; + return _loadRoutineRow(rows.first); + } + + @override + Future deleteRoutine(String id) async { + await _db.transaction((txn) async { + await txn.delete('routine_exercises', where: 'routine_id = ?', whereArgs: [id]); + await txn.delete('routines', where: 'id = ?', whereArgs: [id]); + }); + } + + // ==================== TARGETS ==================== + + @override + Future saveTarget(Target target) async { + await _db.insert( + 'targets', + { + 'id': target.id, + 'exercise_id': target.exerciseId, + 'target_type': target.targetType, + 'target_value': target.targetValue, + 'current_value': target.currentValue, + 'estimated_completion_date': target.estimatedCompletionDate?.toIso8601String(), + 'created_at': target.createdAt.toIso8601String(), + 'is_completed': target.isCompleted ? 1 : 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Target _targetFromRow(Map row) => Target( + id: row['id'] as String, + exerciseId: row['exercise_id'] as String, + targetType: row['target_type'] as String, + targetValue: (row['target_value'] as num).toDouble(), + currentValue: (row['current_value'] as num).toDouble(), + estimatedCompletionDate: row['estimated_completion_date'] == null + ? null + : DateTime.parse(row['estimated_completion_date'] as String), + createdAt: DateTime.parse(row['created_at'] as String), + isCompleted: (row['is_completed'] as int) == 1, + ); + + @override + Future> getAllTargets() async { + final rows = await _db.query('targets'); + return rows.map(_targetFromRow).toList(); + } + + @override + Future getTarget(String id) async { + final rows = await _db.query('targets', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _targetFromRow(rows.first); + } + + @override + Future deleteTarget(String id) async { + await _db.delete('targets', where: 'id = ?', whereArgs: [id]); + } + + @override + Future> getTargetsForExercise(String exerciseId) async { + final rows = await _db.query('targets', where: 'exercise_id = ?', whereArgs: [exerciseId]); + return rows.map(_targetFromRow).toList(); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: PASS (all tests, including Task 2's). + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/sqlite_storage_service.dart workout-logger/test/sqlite_storage_service_test.dart +git commit -m "feat: implement routine and target CRUD in SqliteStorageService" +``` + +--- + +### Task 4: `SqliteStorageService` — muscle groups + custom exercises + +**Files:** +- Modify: `workout-logger/lib/services/sqlite_storage_service.dart` +- Modify: `workout-logger/test/sqlite_storage_service_test.dart` + +**Interfaces:** +- Consumes: `exercises`, `exercise_muscle_activations`, `muscle_groups` tables; `ExerciseDatabase.getAll()` / `getById()` for built-ins. +- Produces: working `updateMuscleGroupGrowthRate`, `getAllMuscleGroups`, `getMuscleGroup`, `saveCustomExercise`, `getCustomExercises`, `deleteCustomExercise`, `getAllExercises`, `getExercise`. + +- [ ] **Step 1: Write the failing tests** + +Append to `workout-logger/test/sqlite_storage_service_test.dart`: + +```dart + group('SqliteStorageService — muscle groups', () { + test('updateMuscleGroupGrowthRate updates an existing group', () async { + final groups = await storage.getAllMuscleGroups(); + final chest = groups.firstWhere((g) => g.name == 'Chest'); + await storage.updateMuscleGroupGrowthRate(chest.id, 2.5); + final updated = await storage.getMuscleGroup(chest.id); + expect(updated!.growthRate, 2.5); + }); + }); + + group('SqliteStorageService — custom exercises', () { + test('saveCustomExercise + getExercise round-trips muscle activations', () async { + final exercise = Exercise( + id: 'custom1', + name: 'Cable Crossover', + category: 'isolation', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 80), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 20), + ], + ); + await storage.saveCustomExercise(exercise); + + final fetched = await storage.getExercise('custom1'); + expect(fetched, isNotNull); + expect(fetched!.name, 'Cable Crossover'); + expect(fetched.muscleActivations.length, 2); + expect(fetched.primaryMuscle, 'chest'); + }); + + test('getExercise falls back to built-in exercises', () async { + final builtIns = ExerciseDatabase.getAll(); + final known = builtIns.first; + final fetched = await storage.getExercise(known.id); + expect(fetched!.name, known.name); + }); + + test('getAllExercises merges built-in and custom', () async { + await storage.saveCustomExercise(Exercise( + id: 'custom2', + name: 'My Exercise', + category: 'compound', + isCustom: true, + muscleActivations: [MuscleActivation(muscleGroupId: 'back', activationPercentage: 100)], + )); + final all = await storage.getAllExercises(); + expect(all.any((e) => e.id == 'custom2'), isTrue); + expect(all.length, greaterThan(1)); + }); + + test('deleteCustomExercise removes it and its activations', () async { + await storage.saveCustomExercise(Exercise( + id: 'custom3', + name: 'Temp', + category: 'isolation', + isCustom: true, + muscleActivations: [MuscleActivation(muscleGroupId: 'biceps', activationPercentage: 100)], + )); + await storage.deleteCustomExercise('custom3'); + expect(await storage.getExercise('custom3'), isNull); + final custom = await storage.getCustomExercises(); + expect(custom.any((e) => e.id == 'custom3'), isFalse); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: FAIL with `UnimplementedError` on the muscle group / custom exercise tests. + +- [ ] **Step 3: Implement muscle groups + custom exercises** + +In `workout-logger/lib/services/sqlite_storage_service.dart`, replace the `// ==================== MUSCLE GROUPS / EXERCISES (Task 4) ====================` section with: + +```dart + // ==================== MUSCLE GROUPS ==================== + + @override + Future updateMuscleGroupGrowthRate(String muscleGroupId, double rate) async { + await _db.update( + 'muscle_groups', + {'growth_rate': rate, 'last_updated': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [muscleGroupId], + ); + } + + MuscleGroup _muscleGroupFromRow(Map row) => MuscleGroup( + id: row['id'] as String, + name: row['name'] as String, + growthRate: (row['growth_rate'] as num).toDouble(), + lastUpdated: DateTime.parse(row['last_updated'] as String), + ); + + @override + Future> getAllMuscleGroups() async { + final rows = await _db.query('muscle_groups'); + return rows.map(_muscleGroupFromRow).toList(); + } + + @override + Future getMuscleGroup(String id) async { + final rows = await _db.query('muscle_groups', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _muscleGroupFromRow(rows.first); + } + + // ==================== CUSTOM EXERCISES ==================== + + @override + Future saveCustomExercise(Exercise exercise) async { + await _db.transaction((txn) async { + await txn.delete('exercise_muscle_activations', where: 'exercise_id = ?', whereArgs: [exercise.id]); + await txn.insert( + 'exercises', + { + 'id': exercise.id, + 'name': exercise.name, + 'category': exercise.category, + 'is_custom': 1, + 'available_handles': + exercise.availableHandles == null ? null : jsonEncode(exercise.availableHandles), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (final ma in exercise.muscleActivations) { + await txn.insert('exercise_muscle_activations', { + 'exercise_id': exercise.id, + 'muscle_group_id': ma.muscleGroupId, + 'activation_percentage': ma.activationPercentage, + }); + } + }); + } + + Future _loadCustomExerciseRow(Map row) async { + final activations = await _db.query( + 'exercise_muscle_activations', + where: 'exercise_id = ?', + whereArgs: [row['id']], + ); + return Exercise( + id: row['id'] as String, + name: row['name'] as String, + category: row['category'] as String, + isCustom: true, + availableHandles: row['available_handles'] == null + ? null + : (jsonDecode(row['available_handles'] as String) as List).cast(), + muscleActivations: activations + .map((a) => MuscleActivation( + muscleGroupId: a['muscle_group_id'] as String, + activationPercentage: a['activation_percentage'] as int, + )) + .toList(), + ); + } + + @override + Future> getCustomExercises() async { + final rows = await _db.query('exercises'); + final result = []; + for (final row in rows) { + result.add(await _loadCustomExerciseRow(row)); + } + return result; + } + + @override + Future deleteCustomExercise(String id) async { + await _db.transaction((txn) async { + await txn.delete('exercise_muscle_activations', where: 'exercise_id = ?', whereArgs: [id]); + await txn.delete('exercises', where: 'id = ?', whereArgs: [id]); + }); + } + + @override + Future> getAllExercises() async { + final builtIn = ExerciseDatabase.getAll(); + final custom = await getCustomExercises(); + return [...builtIn, ...custom]; + } + + @override + Future getExercise(String id) async { + final builtIn = ExerciseDatabase.getById(id); + if (builtIn != null) return builtIn; + final rows = await _db.query('exercises', where: 'id = ?', whereArgs: [id]); + if (rows.isEmpty) return null; + return _loadCustomExerciseRow(rows.first); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/sqlite_storage_service.dart workout-logger/test/sqlite_storage_service_test.dart +git commit -m "feat: implement muscle group and custom exercise CRUD in SqliteStorageService" +``` + +--- + +### Task 5: `SqliteStorageService` — settings, personal records, training programs, conversations, stats + +**Files:** +- Modify: `workout-logger/lib/services/sqlite_storage_service.dart` +- Modify: `workout-logger/test/sqlite_storage_service_test.dart` + +**Interfaces:** +- Consumes: `settings`, `personal_records`, `training_programs`, `conversations` tables. `TrainingPhase`/`ProgramWeek`/`ChatMessage` `toJson`/`fromJson` (already defined in `lib/models/models.dart`, used the same way the existing Hive `StorageService` uses them). +- Produces: working `saveSetting`, `getSetting`, `savePersonalRecord`, `getPersonalRecord`, `getAllPersonalRecords`, `saveTrainingProgram`, `getAllTrainingPrograms`, `getTrainingProgram`, `deleteTrainingProgram`, `saveConversation`, `getAllConversations`, `getConversation`, `deleteConversation`, `getQuickStats`. + +- [ ] **Step 1: Write the failing tests** + +Append to `workout-logger/test/sqlite_storage_service_test.dart`: + +```dart + group('SqliteStorageService — settings', () { + test('saveSetting + getSetting round-trips, overwrite replaces value', () async { + await storage.saveSetting('user_name', 'Alex'); + expect(await storage.getSetting('user_name'), 'Alex'); + await storage.saveSetting('user_name', 'Sam'); + expect(await storage.getSetting('user_name'), 'Sam'); + }); + + test('getSetting returns null for unknown key', () async { + expect(await storage.getSetting('does_not_exist'), isNull); + }); + }); + + group('SqliteStorageService — personal records', () { + test('savePersonalRecord + getPersonalRecord round-trips', () async { + await storage.savePersonalRecord(PersonalRecord( + exerciseId: 'bench_press', + bestWeight: 90, + bestReps: 5, + bestVolume: 450, + achievedAt: DateTime(2026, 4, 1), + )); + final pr = await storage.getPersonalRecord('bench_press'); + expect(pr!.bestWeight, 90); + }); + + test('getAllPersonalRecords returns everything saved', () async { + await storage.savePersonalRecord(PersonalRecord( + exerciseId: 'squat', bestWeight: 150, bestReps: 3, bestVolume: 450, achievedAt: DateTime(2026, 3, 1), + )); + final all = await storage.getAllPersonalRecords(); + expect(all.any((r) => r.exerciseId == 'squat'), isTrue); + }); + }); + + group('SqliteStorageService — training programs', () { + test('saveTrainingProgram + getTrainingProgram round-trips phases/weeks', () async { + final program = TrainingProgram( + id: 'p1', + name: '12-Week Strength', + totalWeeks: 12, + phases: [], + weeks: [], + ); + await storage.saveTrainingProgram(program); + final fetched = await storage.getTrainingProgram('p1'); + expect(fetched!.name, '12-Week Strength'); + expect(fetched.totalWeeks, 12); + }); + + test('deleteTrainingProgram removes it', () async { + await storage.saveTrainingProgram(TrainingProgram(id: 'p2', name: 'X', totalWeeks: 4, phases: [], weeks: [])); + await storage.deleteTrainingProgram('p2'); + expect(await storage.getTrainingProgram('p2'), isNull); + }); + }); + + group('SqliteStorageService — conversations', () { + test('saveConversation + getConversation round-trips messages', () async { + final conversation = Conversation( + id: 'c1', + title: 'Progress check', + messages: [ChatMessage(role: 'user', text: 'How is my bench doing?')], + ); + await storage.saveConversation(conversation); + final fetched = await storage.getConversation('c1'); + expect(fetched!.messages.single.text, 'How is my bench doing?'); + }); + + test('getAllConversations returns most-recently-updated first', () async { + await storage.saveConversation(Conversation( + id: 'c2', title: 'Old', updatedAt: DateTime(2026, 1, 1), messages: [], + )); + await storage.saveConversation(Conversation( + id: 'c3', title: 'New', updatedAt: DateTime(2026, 6, 1), messages: [], + )); + final all = await storage.getAllConversations(); + expect(all.first.id, 'c3'); + }); + + test('deleteConversation removes it', () async { + await storage.saveConversation(Conversation(id: 'c4', title: 'Temp', messages: [])); + await storage.deleteConversation('c4'); + expect(await storage.getConversation('c4'), isNull); + }); + }); + + group('SqliteStorageService — quick stats', () { + test('getQuickStats aggregates the last 7 days', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'stat1', + date: DateTime.now(), + duration: 30, + exercises: [ExerciseLog(exerciseId: 'bench_press', sets: [WorkoutSet(weight: 60, reps: 10)])], + )); + final stats = await storage.getQuickStats(); + expect(stats['totalWorkouts'], greaterThanOrEqualTo(1)); + expect(stats['weeklyWorkouts'], greaterThanOrEqualTo(1)); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: FAIL with `UnimplementedError` on the new tests. + +- [ ] **Step 3: Implement settings, PRs, training programs, conversations, stats** + +In `workout-logger/lib/services/sqlite_storage_service.dart`, replace the `// ==================== SETTINGS / PROGRAMS / PRs / CONVERSATIONS (Task 5) ====================` section with: + +```dart + // ==================== SETTINGS ==================== + + @override + Future saveSetting(String key, String value) async { + await _db.insert('settings', {'key': key, 'value': value}, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + @override + Future getSetting(String key) async { + final rows = await _db.query('settings', where: 'key = ?', whereArgs: [key]); + return rows.isEmpty ? null : rows.first['value'] as String?; + } + + // ==================== TRAINING PROGRAMS ==================== + + @override + Future saveTrainingProgram(TrainingProgram program) async { + await _db.insert( + 'training_programs', + { + 'id': program.id, + 'name': program.name, + 'description': program.description, + 'total_weeks': program.totalWeeks, + 'author': program.author, + 'is_imported': program.isImported ? 1 : 0, + 'created_at': program.createdAt.toIso8601String(), + 'phases_json': jsonEncode(program.phases.map((p) => p.toJson()).toList()), + 'weeks_json': jsonEncode(program.weeks.map((w) => w.toJson()).toList()), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + TrainingProgram _programFromRow(Map row) => TrainingProgram( + id: row['id'] as String, + name: row['name'] as String, + description: row['description'] as String?, + totalWeeks: row['total_weeks'] as int, + phases: (jsonDecode(row['phases_json'] as String) as List) + .map((p) => TrainingPhase.fromJson(p as Map)) + .toList(), + weeks: (jsonDecode(row['weeks_json'] as String) as List) + .map((w) => ProgramWeek.fromJson(w as Map)) + .toList(), + author: row['author'] as String?, + isImported: (row['is_imported'] as int) == 1, + createdAt: DateTime.parse(row['created_at'] as String), + ); + + @override + Future> getAllTrainingPrograms() async { + final rows = await _db.query('training_programs', orderBy: 'created_at DESC'); + return rows.map(_programFromRow).toList(); + } + + @override + Future getTrainingProgram(String id) async { + final rows = await _db.query('training_programs', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _programFromRow(rows.first); + } + + @override + Future deleteTrainingProgram(String id) async { + await _db.delete('training_programs', where: 'id = ?', whereArgs: [id]); + } + + // ==================== PERSONAL RECORDS ==================== + + @override + Future savePersonalRecord(PersonalRecord record) async { + await _db.insert( + 'personal_records', + { + 'exercise_id': record.exerciseId, + 'best_weight': record.bestWeight, + 'best_reps': record.bestReps, + 'best_volume': record.bestVolume, + 'achieved_at': record.achievedAt.toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + PersonalRecord _prFromRow(Map row) => PersonalRecord( + exerciseId: row['exercise_id'] as String, + bestWeight: (row['best_weight'] as num).toDouble(), + bestReps: row['best_reps'] as int, + bestVolume: (row['best_volume'] as num).toDouble(), + achievedAt: DateTime.parse(row['achieved_at'] as String), + ); + + @override + Future getPersonalRecord(String exerciseId) async { + final rows = await _db.query('personal_records', where: 'exercise_id = ?', whereArgs: [exerciseId]); + return rows.isEmpty ? null : _prFromRow(rows.first); + } + + @override + Future> getAllPersonalRecords() async { + final rows = await _db.query('personal_records'); + return rows.map(_prFromRow).toList(); + } + + // ==================== AI CONVERSATIONS ==================== + + @override + Future saveConversation(Conversation conversation) async { + await _db.insert( + 'conversations', + { + 'id': conversation.id, + 'title': conversation.title, + 'kind': conversation.kind, + 'created_at': conversation.createdAt.toIso8601String(), + 'updated_at': conversation.updatedAt.toIso8601String(), + 'messages_json': jsonEncode(conversation.messages.map((m) => m.toJson()).toList()), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Conversation _conversationFromRow(Map row) => Conversation( + id: row['id'] as String, + title: row['title'] as String, + kind: row['kind'] as String, + createdAt: DateTime.parse(row['created_at'] as String), + updatedAt: DateTime.parse(row['updated_at'] as String), + messages: (jsonDecode(row['messages_json'] as String) as List) + .map((m) => ChatMessage.fromJson(m as Map)) + .toList(), + ); + + @override + Future> getAllConversations() async { + final rows = await _db.query('conversations', orderBy: 'updated_at DESC'); + return rows.map(_conversationFromRow).toList(); + } + + @override + Future getConversation(String id) async { + final rows = await _db.query('conversations', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _conversationFromRow(rows.first); + } + + @override + Future deleteConversation(String id) async { + await _db.delete('conversations', where: 'id = ?', whereArgs: [id]); + } + + // ==================== STATS ==================== + + @override + Future> getQuickStats() async { + final sessions = await getAllWorkoutSessions(); + final now = DateTime.now(); + final weekAgo = now.subtract(const Duration(days: 7)); + final weekSessions = sessions.where((s) => s.date.isAfter(weekAgo)).toList(); + + double weeklyVolume = 0; + int exercisesCompleted = 0; + for (var session in weekSessions) { + weeklyVolume += session.totalVolume; + exercisesCompleted += session.exercises.length; + } + + return { + 'totalWorkouts': sessions.length, + 'weeklyWorkouts': weekSessions.length, + 'weeklyVolume': weeklyVolume, + 'exercisesThisWeek': exercisesCompleted, + }; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/sqlite_storage_service.dart workout-logger/test/sqlite_storage_service_test.dart +git commit -m "feat: implement settings, PR, training program, and conversation CRUD in SqliteStorageService" +``` + +--- + +### Task 6: `SqliteStorageService` — export/import + +**Files:** +- Modify: `workout-logger/lib/services/sqlite_storage_service.dart` +- Modify: `workout-logger/test/sqlite_storage_service_test.dart` + +**Interfaces:** +- Consumes: all read/write methods implemented in Tasks 2–5. +- Produces: working `exportAllData` (JSON shape matching the existing Hive `StorageService.exportAllData` — same top-level keys: `sessions`, `routines`, `targets`, `muscleGroups`, `customExercises`, `conversations`, `settings`, `exportDate`, `appVersion`) and `importData` (same merge-skip-existing semantics as Hive's). + +- [ ] **Step 1: Write the failing tests** + +Append to `workout-logger/test/sqlite_storage_service_test.dart`: + +```dart + group('SqliteStorageService — export/import', () { + test('exportAllData includes sessions, routines, settings', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'exp1', date: DateTime(2026, 5, 1), duration: 20, + exercises: [ExerciseLog(exerciseId: 'row', sets: [WorkoutSet(weight: 40, reps: 10)])], + )); + await storage.saveRoutine(Routine(id: 'exp_r1', name: 'Export Routine', exerciseIds: ['row'])); + await storage.saveSetting('unit', 'kg'); + + final json = await storage.exportAllData(); + final data = jsonDecode(json) as Map; + + expect((data['sessions'] as List).any((s) => s['id'] == 'exp1'), isTrue); + expect((data['routines'] as List).any((r) => r['id'] == 'exp_r1'), isTrue); + expect((data['settings'] as Map)['unit'], 'kg'); + }); + + test('importData merges without overwriting existing ids', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'imp1', date: DateTime(2026, 1, 1), duration: 15, + exercises: [ExerciseLog(exerciseId: 'row', sets: [WorkoutSet(weight: 30, reps: 12)])], + )); + + final payload = jsonEncode({ + 'sessions': [ + { + 'id': 'imp1', // already exists — must be skipped + 'date': DateTime(2099, 1, 1).toIso8601String(), + 'duration': 999, + 'exercises': [], + }, + { + 'id': 'imp2', // new — must be imported + 'date': DateTime(2026, 2, 1).toIso8601String(), + 'duration': 25, + 'exercises': [], + }, + ], + 'settings': {'imported_key': 'imported_value'}, + }); + + await storage.importData(payload); + + final existing = await storage.getWorkoutSession('imp1'); + expect(existing!.duration, 15); // untouched + final imported = await storage.getWorkoutSession('imp2'); + expect(imported!.duration, 25); + expect(await storage.getSetting('imported_key'), 'imported_value'); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: FAIL with `UnimplementedError` on export/import tests. + +- [ ] **Step 3: Implement export/import** + +In `workout-logger/lib/services/sqlite_storage_service.dart`, replace the `// ==================== EXPORT / IMPORT (Task 6) ====================` section with: + +```dart + // ==================== EXPORT / IMPORT ==================== + + Map? _normalizeImportItem(dynamic item) { + if (item is Map) return item; + if (item is Map) return Map.from(item); + if (item is String) { + try { + final decoded = jsonDecode(item); + if (decoded is Map) return Map.from(decoded); + } catch (_) { + return null; + } + } + return null; + } + + @override + Future exportAllData() async { + final sessions = await getAllWorkoutSessions(); + final routines = await getAllRoutines(); + final targets = await getAllTargets(); + final muscleGroups = await getAllMuscleGroups(); + final customExercises = await getCustomExercises(); + final conversations = await getAllConversations(); + final settingsRows = await _db.query('settings'); + final settingsMap = { + for (final row in settingsRows) + if (row['value'] != null) row['key'] as String: row['value'] as String, + }; + + final data = { + 'sessions': sessions.map((s) => s.toJson()).toList(), + 'routines': routines.map((r) => r.toJson()).toList(), + 'targets': targets.map((t) => t.toJson()).toList(), + 'muscleGroups': muscleGroups.map((m) => m.toJson()).toList(), + 'customExercises': customExercises.map((e) => e.toJson()).toList(), + 'conversations': conversations.map((c) => c.toJson()).toList(), + 'settings': settingsMap, + 'exportDate': DateTime.now().toIso8601String(), + 'appVersion': _appVersion, + }; + return jsonEncode(data); + } + + @override + Future importData(String jsonData) async { + final data = jsonDecode(jsonData) as Map; + + final sessions = data['sessions']; + if (sessions is List) { + for (final item in sessions) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final session = WorkoutSession.fromJson(map); + if (await getWorkoutSession(session.id) == null) { + await saveWorkoutSession(session); + } + } + } + + final routines = data['routines']; + if (routines is List) { + for (final item in routines) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final routine = Routine.fromJson(map); + if (await getRoutine(routine.id) == null) { + await saveRoutine(routine); + } + } + } + + final targets = data['targets']; + if (targets is List) { + for (final item in targets) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final target = Target.fromJson(map); + if (await getTarget(target.id) == null) { + await saveTarget(target); + } + } + } + + final muscleGroups = data['muscleGroups']; + if (muscleGroups is List) { + for (final item in muscleGroups) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final mg = MuscleGroup.fromJson(map); + if (await getMuscleGroup(mg.id) == null) { + await _db.insert('muscle_groups', { + 'id': mg.id, + 'name': mg.name, + 'growth_rate': mg.growthRate, + 'last_updated': mg.lastUpdated.toIso8601String(), + }); + } + } + } + + final customExercises = data['customExercises']; + if (customExercises is List) { + for (final item in customExercises) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final exercise = Exercise.fromJson(map); + final rows = await _db.query('exercises', where: 'id = ?', whereArgs: [exercise.id]); + if (rows.isEmpty) { + await saveCustomExercise(exercise); + } + } + } + + if (data['settings'] is Map) { + final settings = data['settings'] as Map; + for (final entry in settings.entries) { + if (await getSetting(entry.key) == null) { + await saveSetting(entry.key, entry.value.toString()); + } + } + } + + final conversations = data['conversations']; + if (conversations is List) { + for (final item in conversations) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final conversation = Conversation.fromJson(map); + if (await getConversation(conversation.id) == null) { + await saveConversation(conversation); + } + } + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/sqlite_storage_service_test.dart` +Expected: PASS — full file, all tasks 2–6 combined (roughly 25 tests). + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/sqlite_storage_service.dart workout-logger/test/sqlite_storage_service_test.dart +git commit -m "feat: implement export/import in SqliteStorageService, completing IStorageService" +``` + +--- + +### Task 7: `StorageService` (Hive) — settings enumeration for migration + +**Files:** +- Modify: `workout-logger/lib/services/storage_service.dart` +- Test: `workout-logger/test/storage_service_test.dart` + +**Interfaces:** +- Produces: `Future> getAllSettingsForMigration()` — a concrete-class-only addition (not part of `IStorageService`), used exclusively by `StorageMigrationService` (Task 8) to enumerate every settings key. Not on the interface because no other consumer needs to list all keys. + +- [ ] **Step 1: Write the failing test** + +Append to `workout-logger/test/storage_service_test.dart`, inside the existing `group('StorageService CRUD & Operations', () { ... })`: + +```dart + test('getAllSettingsForMigration returns every saved key/value', () async { + await storage.saveSetting('mig_key_1', 'value_1'); + await storage.saveSetting('mig_key_2', 'value_2'); + + final all = await storage.getAllSettingsForMigration(); + + expect(all['mig_key_1'], 'value_1'); + expect(all['mig_key_2'], 'value_2'); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/storage_service_test.dart` +Expected: FAIL — `getAllSettingsForMigration` is not defined on `StorageService`. + +- [ ] **Step 3: Implement the helper** + +In `workout-logger/lib/services/storage_service.dart`, add this method right after `getSetting` (inside the `// ==================== SETTINGS ====================` section): + +```dart + /// Every stored setting key/value. Used only by [StorageMigrationService] + /// to migrate the settings box to the SQLite backend — not part of + /// [IStorageService] since no other consumer needs to enumerate all keys. + Future> getAllSettingsForMigration() async { + final map = {}; + for (final key in _settingsBoxInstance.keys) { + final value = _settingsBoxInstance.get(key); + if (value != null) map[key as String] = value; + } + return map; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/storage_service_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/storage_service.dart workout-logger/test/storage_service_test.dart +git commit -m "feat: add settings enumeration helper to StorageService for migration" +``` + +--- + +### Task 8: `StorageMigrationService` + +**Files:** +- Create: `workout-logger/lib/services/storage_migration_service.dart` +- Test: `workout-logger/test/storage_migration_service_test.dart` + +**Interfaces:** +- Consumes: `StorageService` (Hive, Task 7's `getAllSettingsForMigration`), `SqliteStorageService` (Tasks 2–6, full `IStorageService`). +- Produces: `class StorageMigrationService { StorageMigrationService(StorageService source, SqliteStorageService target); Future migrate(); }`. Throws on any failure (caller in Task 9 decides fallback) — does not catch internally. + +- [ ] **Step 1: Write the failing test** + +Create `workout-logger/test/storage_migration_service_test.dart`: + +```dart +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/storage_service.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; +import 'package:repforge/services/storage_migration_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StorageService hiveStorage; + late SqliteStorageService sqliteStorage; + + setUpAll(() async { + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async => + call.method == 'getApplicationDocumentsDirectory' ? './test/tmp_hive_migration_service' : null, + ); + Hive.init('./test/tmp_hive_migration_service'); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + hiveStorage = StorageService(); + await hiveStorage.init(); + sqliteStorage = SqliteStorageService(databasePathOverride: inMemoryDatabasePath); + await sqliteStorage.init(); + }); + + tearDownAll(() async { + await Hive.close(); + await Hive.deleteFromDisk(); + }); + + test('migrate copies every entity type from Hive to SQLite', () async { + await hiveStorage.saveWorkoutSession(WorkoutSession( + id: 'sess1', date: DateTime(2026, 5, 1), duration: 40, + exercises: [ExerciseLog(exerciseId: 'bench_press', sets: [WorkoutSet(weight: 70, reps: 8)])], + )); + await hiveStorage.saveRoutine(Routine(id: 'r1', name: 'Push Day', exerciseIds: ['bench_press'])); + await hiveStorage.saveTarget(Target(id: 't1', exerciseId: 'bench_press', targetType: 'weight', targetValue: 100)); + await hiveStorage.savePersonalRecord(PersonalRecord( + exerciseId: 'bench_press', bestWeight: 90, bestReps: 5, bestVolume: 450, achievedAt: DateTime(2026, 4, 1), + )); + await hiveStorage.saveCustomExercise(Exercise( + id: 'custom_mig', name: 'Migrated Exercise', category: 'isolation', isCustom: true, + muscleActivations: [MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100)], + )); + await hiveStorage.saveConversation(Conversation(id: 'conv1', title: 'Chat', messages: [])); + await hiveStorage.saveSetting('user_name', 'Alex'); + + await StorageMigrationService(hiveStorage, sqliteStorage).migrate(); + + expect((await sqliteStorage.getWorkoutSession('sess1'))?.duration, 40); + expect((await sqliteStorage.getRoutine('r1'))?.name, 'Push Day'); + expect((await sqliteStorage.getTarget('t1'))?.targetValue, 100); + expect((await sqliteStorage.getPersonalRecord('bench_press'))?.bestWeight, 90); + expect((await sqliteStorage.getExercise('custom_mig'))?.name, 'Migrated Exercise'); + expect((await sqliteStorage.getConversation('conv1'))?.title, 'Chat'); + expect(await sqliteStorage.getSetting('user_name'), 'Alex'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/storage_migration_service_test.dart` +Expected: FAIL — `lib/services/storage_migration_service.dart` does not exist. + +- [ ] **Step 3: Implement `StorageMigrationService`** + +Create `workout-logger/lib/services/storage_migration_service.dart`: + +```dart +// One-time migration from the Hive-backed StorageService to +// SqliteStorageService. Reads exclusively through StorageService's existing, +// already-correct read methods; writes exclusively through +// SqliteStorageService's write methods. Throws on any failure — the caller +// (main.dart) decides whether to fall back to Hive. See +// docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md §6. + +import 'storage_service.dart'; +import 'sqlite_storage_service.dart'; + +class StorageMigrationService { + StorageMigrationService(this._source, this._target); + + final StorageService _source; + final SqliteStorageService _target; + + Future migrate() async { + for (final session in await _source.getAllWorkoutSessions()) { + await _target.saveWorkoutSession(session); + } + for (final routine in await _source.getAllRoutines()) { + await _target.saveRoutine(routine); + } + for (final target in await _source.getAllTargets()) { + await _target.saveTarget(target); + } + for (final mg in await _source.getAllMuscleGroups()) { + await _target.updateMuscleGroupGrowthRate(mg.id, mg.growthRate); + } + for (final exercise in await _source.getCustomExercises()) { + await _target.saveCustomExercise(exercise); + } + for (final record in await _source.getAllPersonalRecords()) { + await _target.savePersonalRecord(record); + } + for (final program in await _source.getAllTrainingPrograms()) { + await _target.saveTrainingProgram(program); + } + for (final conversation in await _source.getAllConversations()) { + await _target.saveConversation(conversation); + } + final settings = await _source.getAllSettingsForMigration(); + for (final entry in settings.entries) { + await _target.saveSetting(entry.key, entry.value); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/storage_migration_service_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/storage_migration_service.dart workout-logger/test/storage_migration_service_test.dart +git commit -m "feat: add StorageMigrationService for one-time Hive-to-SQLite migration" +``` + +--- + +### Task 9: Wire the storage backend resolution into `main.dart` + +**Files:** +- Modify: `workout-logger/lib/main.dart` + +**Interfaces:** +- Consumes: `StorageService`, `SqliteStorageService`, `StorageMigrationService` (Tasks 2, 7, 8). +- Produces: top-level `IStorageService? _resolvedStorageService` and `Future _resolveStorageBackend()`, called from `main()` before `runApp()`. `WorkoutLoggerApp._storageService`'s existing `static final` initializer reads `_resolvedStorageService!` — this works correctly because Dart `static`/top-level variables are lazily initialized on first access, and `_storageService` isn't accessed until `build()` runs, by which point `_resolveStorageBackend()` has already completed inside `main()`. + +This task has no automated test — `main()`/composition-root wiring isn't unit-testable without a larger DI refactor that's out of scope here (every dependent entity — `WorkoutProvider`, `CoachToolService`, etc. — is already covered by its own tests against `IStorageService`/`MockStorageService`). Verification is `flutter analyze` plus a manual run. + +- [ ] **Step 1: Add imports** + +In `workout-logger/lib/main.dart`, add these imports alongside the existing `services/storage_service.dart` import: + +```dart +import 'package:hive_flutter/hive_flutter.dart'; +import 'services/sqlite_storage_service.dart'; +import 'services/storage_migration_service.dart'; +import 'services/ai/sql_query_service.dart'; +``` + +- [ ] **Step 2: Add the resolver function** + +In `workout-logger/lib/main.dart`, add this above `void main() async {`: + +```dart +/// Resolved once in main() before runApp(). Read lazily by +/// WorkoutLoggerApp._storageService's static initializer, which only runs +/// on first access (during build()) — by then this is already set. +IStorageService? _resolvedStorageService; + +/// One-time, flag-gated, reversible Hive -> SQLite cutover. See +/// docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md §6. +Future _resolveStorageBackend() async { + await Hive.initFlutter(); + final settingsBox = await Hive.openBox('settings'); + final alreadyMigrated = settingsBox.get('storage_migrated_v1') == 'true'; + + if (alreadyMigrated) { + final sqlite = SqliteStorageService(); + await sqlite.init(); + _resolvedStorageService = sqlite; + return; + } + + final hiveStorage = StorageService(); + await hiveStorage.init(); + final sqliteStorage = SqliteStorageService(); + await sqliteStorage.init(); + + var migrationSucceeded = false; + try { + await StorageMigrationService(hiveStorage, sqliteStorage).migrate(); + await hiveStorage.saveSetting('storage_migrated_v1', 'true'); + migrationSucceeded = true; + } catch (e, st) { + debugPrint('Storage migration to SQLite failed, staying on Hive: $e\n$st'); + } + + _resolvedStorageService = migrationSucceeded ? sqliteStorage : hiveStorage; +} +``` + +- [ ] **Step 3: Call the resolver before `runApp`** + +In `workout-logger/lib/main.dart`, modify `void main() async { ... }` to call the resolver right before `runApp`: + +```dart + // Keep all system overlays transparent; content uses SafeArea for insets + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemStatusBarContrastEnforced: false, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarDividerColor: Colors.transparent, + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarContrastEnforced: false, + ), + ); + + await _resolveStorageBackend(); + + runApp(const WorkoutLoggerApp()); +} +``` + +- [ ] **Step 4: Point the composition root at the resolved backend** + +In `workout-logger/lib/main.dart`, change the `_storageService` static field: + +```dart + static final IStorageService _storageService = StorageService(); +``` + +to: + +```dart + static final IStorageService _storageService = _resolvedStorageService!; +``` + +- [ ] **Step 5: Wire the coach's SQL tool, only when SQLite is active** + +In `workout-logger/lib/main.dart`, modify the `Provider` block: + +```dart + // CoachToolService backs AI tool calls; reads from WorkoutProvider + PRManager. + Provider( + create: (ctx) => CoachToolService( + ctx.read(), + ctx.read(), + healthHistory: ctx.read(), + ), + ), +``` + +to: + +```dart + // CoachToolService backs AI tool calls; reads from WorkoutProvider + PRManager. + // run_sql_query is only offered once the app has cut over to SQLite — + // it needs a live database file to open a read-only connection against. + Provider( + create: (ctx) => CoachToolService( + ctx.read(), + ctx.read(), + healthHistory: ctx.read(), + sqlQuery: _storageService is SqliteStorageService + ? SqlQueryService((_storageService as SqliteStorageService).databasePath) + : null, + ), + ), +``` + +- [ ] **Step 6: Verify with static analysis** + +Run: `cd workout-logger && flutter analyze lib/main.dart` +Expected: no errors. (`sqlQuery` and `SqlQueryService` won't exist yet — Task 11 adds the `CoachToolService` constructor parameter. If `flutter analyze` fails here because of that, that's expected; re-run this step after Task 11 instead and treat this as a checkpoint, not a blocker to committing Task 9's `main.dart` changes on their own branch state.) + +- [ ] **Step 7: Commit** + +```bash +git add workout-logger/lib/main.dart +git commit -m "feat: resolve Hive-vs-SQLite storage backend in main() before runApp" +``` + +--- + +### Task 10: `SqlQueryService` — read-only SQL execution + +**Files:** +- Create: `workout-logger/lib/services/ai/sql_query_service.dart` +- Test: `workout-logger/test/sql_query_service_test.dart` + +**Interfaces:** +- Produces: `class SqlQueryService { SqlQueryService(String databasePath); Future> runQuery(String rawQuery, {int? limit}); }`. Returns `{'row_count': int, 'rows': List>}` on success, `{'error': String}` on any validation or execution failure — never throws. + +- [ ] **Step 1: Write the failing test** + +Create `workout-logger/test/sql_query_service_test.dart`: + +```dart +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/services/ai/sql_query_service.dart'; + +void main() { + late String dbPath; + late Database seedDb; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + dbPath = '${Directory.systemTemp.path}/sql_query_test_${DateTime.now().microsecondsSinceEpoch}.db'; + seedDb = await openDatabase(dbPath, version: 1, onCreate: (db, _) async { + await db.execute('CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)'); + await db.insert('widgets', {'id': 1, 'name': 'foo'}); + await db.insert('widgets', {'id': 2, 'name': 'bar'}); + }); + }); + + tearDown(() async { + await seedDb.close(); + final f = File(dbPath); + if (await f.exists()) await f.delete(); + }); + + test('valid SELECT returns rows', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM widgets ORDER BY id'); + expect(result['row_count'], 2); + expect((result['rows'] as List).first, {'id': 1, 'name': 'foo'}); + }); + + test('rejects non-SELECT statements', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('DELETE FROM widgets'); + expect(result['error'], contains('Only SELECT')); + }); + + test('rejects multi-statement input', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM widgets; DROP TABLE widgets;'); + expect(result['error'], contains('single SQL statement')); + }); + + test('caps row count via limit', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM widgets', limit: 1); + expect(result['row_count'], 1); + }); + + test('returns error map instead of throwing on invalid SQL', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM does_not_exist'); + expect(result['error'], isNotNull); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/sql_query_service_test.dart` +Expected: FAIL — `lib/services/ai/sql_query_service.dart` does not exist. + +- [ ] **Step 3: Implement `SqlQueryService`** + +Create `workout-logger/lib/services/ai/sql_query_service.dart`: + +```dart +// Executes model-submitted read-only SQL against a dedicated read-only +// connection to the app's live SQLite database. Used only by the coach's +// run_sql_query tool — never the app's own read/write connection. See +// docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md §7. + +import 'package:sqflite/sqflite.dart'; + +class SqlValidationException implements Exception { + SqlValidationException(this.message); + final String message; + + @override + String toString() => message; +} + +class SqlQueryService { + SqlQueryService(this.databasePath); + + final String databasePath; + + static const _forbiddenKeywords = [ + 'INSERT', + 'UPDATE', + 'DELETE', + 'DROP', + 'ALTER', + 'CREATE', + 'ATTACH', + 'DETACH', + 'PRAGMA', + 'VACUUM', + 'REPLACE', + 'TRIGGER', + ]; + + String _sanitize(String rawQuery) { + var q = rawQuery.trim(); + if (q.endsWith(';')) { + q = q.substring(0, q.length - 1).trim(); + } + if (q.contains(';')) { + throw SqlValidationException('Only a single SQL statement is allowed.'); + } + final upper = q.toUpperCase(); + if (!(upper.startsWith('SELECT') || upper.startsWith('WITH'))) { + throw SqlValidationException('Only SELECT queries are allowed.'); + } + for (final kw in _forbiddenKeywords) { + if (RegExp('\\b$kw\\b').hasMatch(upper)) { + throw SqlValidationException('Query contains a forbidden keyword: $kw'); + } + } + return q; + } + + /// Runs [rawQuery] read-only and returns {'row_count', 'rows'} on success + /// or {'error': message} on any validation or execution failure. Never + /// throws — callers (the coach tool loop) always get a JSON-safe result. + Future> runQuery(String rawQuery, {int? limit}) async { + final cappedLimit = (limit ?? 200).clamp(1, 500); + + final String safeQuery; + try { + safeQuery = _sanitize(rawQuery); + } on SqlValidationException catch (e) { + return {'error': e.message}; + } + + Database? db; + try { + db = await openReadOnlyDatabase(databasePath); + final rows = await db.rawQuery('SELECT * FROM ($safeQuery) LIMIT ?', [cappedLimit]); + return {'row_count': rows.length, 'rows': rows}; + } catch (e) { + return {'error': 'Query failed: $e'}; + } finally { + await db?.close(); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/sql_query_service_test.dart` +Expected: PASS (all 5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add workout-logger/lib/services/ai/sql_query_service.dart workout-logger/test/sql_query_service_test.dart +git commit -m "feat: add SqlQueryService for read-only SQL execution" +``` + +--- + +### Task 11: Wire `run_sql_query` into `CoachToolService` + +**Files:** +- Modify: `workout-logger/lib/services/ai/coach_tool_service.dart` +- Modify: `workout-logger/test/coach_tool_service_test.dart` + +**Interfaces:** +- Consumes: `SqlQueryService` (Task 10). +- Produces: `CoachToolService(WorkoutProvider, PRManager, {HealthHistoryManager? healthHistory, SqlQueryService? sqlQuery})`. `run_sql_query` is only advertised in `buildTools()` when `sqlQuery` is non-null. + +- [ ] **Step 1: Write the failing tests** + +In `workout-logger/test/coach_tool_service_test.dart`, add these imports at the top: + +```dart +import 'dart:io'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; +import 'package:repforge/services/ai/sql_query_service.dart'; +``` + +Then, inside the existing `group('CoachToolService', () { ... })` (after the existing `setUp`), add a nested group: + +```dart + group('run_sql_query', () { + late String dbPath; + late SqliteStorageService sqliteStorage; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + dbPath = '${Directory.systemTemp.path}/coach_sql_test_${DateTime.now().microsecondsSinceEpoch}.db'; + sqliteStorage = SqliteStorageService(databasePathOverride: dbPath); + await sqliteStorage.init(); + await sqliteStorage.saveWorkoutSession(WorkoutSession( + id: 'sess1', date: DateTime(2026, 5, 1), duration: 40, + exercises: [ExerciseLog(exerciseId: 'bench_press', sets: [WorkoutSet(weight: 70, reps: 8)])], + )); + }); + + tearDown(() async { + final f = File(dbPath); + if (await f.exists()) await f.delete(); + }); + + test('is not advertised when no SqlQueryService is provided', () { + final declared = + tools.buildTools().expand((t) => t.functionDeclarations ?? []).map((f) => f.name); + expect(declared, isNot(contains('run_sql_query'))); + }); + + test('is advertised and runs a live SELECT when wired', () async { + final withSql = CoachToolService(provider, pr, sqlQuery: SqlQueryService(dbPath)); + + final declared = + withSql.buildTools().expand((t) => t.functionDeclarations ?? []).map((f) => f.name); + expect(declared, contains('run_sql_query')); + + final result = await withSql.handleCall( + FunctionCall('run_sql_query', {'query': 'SELECT id, duration_min FROM sessions'}), + ); + expect(result['row_count'], 1); + expect((result['rows'] as List).first, {'id': 'sess1', 'duration_min': 40}); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd workout-logger && flutter test test/coach_tool_service_test.dart` +Expected: FAIL — `sqlQuery` is not a recognized named parameter on `CoachToolService`. + +- [ ] **Step 3: Wire the tool** + +In `workout-logger/lib/services/ai/coach_tool_service.dart`: + +Add the import at the top, alongside the existing imports: + +```dart +import 'sql_query_service.dart'; +``` + +Change the class fields and constructor: + +```dart +class CoachToolService { + final WorkoutProvider _wp; + final PRManager _pr; + final HealthHistoryManager? _hh; + final SqlQueryService? _sql; + + CoachToolService( + this._wp, + this._pr, { + HealthHistoryManager? healthHistory, + SqlQueryService? sqlQuery, + }) : _hh = healthHistory, + _sql = sqlQuery; +``` + +In `buildTools()`, find the closing of the `functionDeclarations` list (right after the `get_sleeping_hr_analytics` declaration, before the final `]),` that closes the `Tool(...)`), and add the conditional entry: + +```dart + FunctionDeclaration( + 'get_sleeping_hr_analytics', + // ... (existing declaration body, unchanged) + ), + if (_sql != null) _runSqlQueryDeclaration, + ]), + ]; +``` + +Add this getter right after `buildTools()` (before `/// Dispatch a model function call...`): + +```dart + /// Schema-aware declaration for run_sql_query — only included when a + /// SqlQueryService is wired (i.e. the app has cut over to SQLite). + FunctionDeclaration get _runSqlQueryDeclaration => FunctionDeclaration( + 'run_sql_query', + 'Run a read-only SQL SELECT query directly against the workout database ' + 'for questions the other tools cannot answer (custom joins, filters, ' + 'or aggregations). Tables:\n' + 'sessions(id, date, routine_id, duration_min, notes, hc_synced_at)\n' + 'exercise_logs(id, session_id, exercise_id, notes, handle)\n' + 'sets(id, exercise_log_id, weight, reps, is_dropset, drops_json, ' + 'time_taken, timestamp, assist_weight, extra_weight, handle)\n' + 'exercises(id, name, category, is_custom, available_handles) — custom ' + 'exercises only; built-ins are not stored here\n' + 'muscle_groups(id, name, growth_rate, last_updated)\n' + 'exercise_muscle_activations(exercise_id, muscle_group_id, activation_percentage)\n' + 'routines(id, name, created_at)\n' + 'routine_exercises(routine_id, exercise_id, position)\n' + 'targets(id, exercise_id, target_type, target_value, current_value, ' + 'estimated_completion_date, created_at, is_completed)\n' + 'personal_records(exercise_id, best_weight, best_reps, best_volume, achieved_at)\n' + 'Only SELECT/WITH statements are allowed, one statement per call.', + Schema.object( + properties: { + 'query': Schema.string( + description: 'A single read-only SQL SELECT statement.', + ), + 'limit': Schema.integer( + description: 'Optional. Max rows to return (default 200, max 500).', + nullable: true, + ), + }, + requiredProperties: ['query'], + ), + ); +``` + +In `handleCall()`, add a case to the `switch (call.name)`: + +```dart + case 'run_sql_query': + final sql = _sql; + if (sql == null) return {'error': 'SQL query tool is not available.'}; + return await sql.runQuery( + (call.args['query'] as String?) ?? '', + limit: (call.args['limit'] as num?)?.toInt(), + ); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd workout-logger && flutter test test/coach_tool_service_test.dart` +Expected: PASS (all existing tests plus the two new `run_sql_query` tests). + +- [ ] **Step 5: Re-verify `main.dart` now compiles end-to-end** + +Run: `cd workout-logger && flutter analyze lib/main.dart lib/services/ai/coach_tool_service.dart` +Expected: no errors — this closes the loop left open at the end of Task 9 Step 6. + +- [ ] **Step 6: Commit** + +```bash +git add workout-logger/lib/services/ai/coach_tool_service.dart workout-logger/test/coach_tool_service_test.dart +git commit -m "feat: wire run_sql_query tool into CoachToolService" +``` + +--- + +### Task 12: Full verification + +**Files:** none (verification only). + +- [ ] **Step 1: Static analysis across the whole project** + +Run: `cd workout-logger && flutter analyze` +Expected: no errors introduced by this plan's changes (pre-existing warnings, if any, are out of scope). + +- [ ] **Step 2: Full test suite** + +Run: `cd workout-logger && flutter test` +Expected: all tests pass, including every test added in Tasks 2–11 plus the full pre-existing suite (managers, providers, screens — all unaffected since they depend on `IStorageService`/`MockStorageService`, never a concrete backend). + +- [ ] **Step 3: Manual smoke test — fresh install path** + +Run: `cd workout-logger && flutter run` (with no existing app data, e.g. a fresh emulator or `flutter clean` + reinstall). +Expected: app launches normally, `storage_migrated_v1` gets set on first launch (no prior Hive data to migrate, so migration is instant), Coach chat still works, and asking the Coach a question that needs `run_sql_query` (e.g. "what's the total volume for each exercise this month, sorted highest to lowest?") produces a sensible answer — confirms the tool is both advertised and functional against real live data. + +- [ ] **Step 4: Manual smoke test — upgrade path (if a build with existing Hive data is available)** + +Install a version prior to this change, log a few workouts, then install this branch's build over it. +Expected: app launches normally, prior workout history is visible (now served from SQLite), and re-launching the app a second time does not re-run the migration (check via logs — `_resolveStorageBackend` should hit the `alreadyMigrated` branch and skip straight to opening `SqliteStorageService`). + +- [ ] **Step 5: Commit (if any fixups were needed)** + +```bash +git add -A +git commit -m "chore: fix issues found during full verification of SQLite migration" +``` + +(Skip this step if Steps 1–4 all passed cleanly with no changes needed.) diff --git a/docs/superpowers/plans/2026-08-11-health-data-sync-and-coach-sql.md b/docs/superpowers/plans/2026-08-11-health-data-sync-and-coach-sql.md new file mode 100644 index 0000000..5d9e210 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-health-data-sync-and-coach-sql.md @@ -0,0 +1,1032 @@ +# Health Data Sync (Sleep + HR) into SQLite — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist sleep and heart-rate data from Health Connect into new SQLite tables so the AI coach's `run_sql_query` tool can join workout data against health data in a single query. + +**Architecture:** A new `HealthDataSyncService` reads from the existing `IHealthConnectService` abstraction and writes into three new tables via new methods added directly on `SqliteStorageService` (not on `IStorageService` — this data has no manager/provider consumer, matching how `SqlQueryService` already bypasses that interface). Sync runs once per app launch (throttled 30 min) plus on-demand via a "Sync now" button on the Profile screen. `run_sql_query`'s embedded schema description is extended with the new tables. + +**Tech Stack:** Flutter/Dart, `sqflite` (already a dependency), `sqflite_common_ffi` (test-only, already a dev dependency), `provider`. + +## Global Constraints + +- No new pubspec dependencies. +- No changes to `IStorageService`'s method signatures, `MockStorageService`, or any manager/provider — the new tables and methods are additive on `SqliteStorageService` only. +- 90-day backfill window on first sync per stream; subsequent syncs re-fetch from `watermark - 3 days` (look-back for late corrections). +- 30-minute throttle between non-forced syncs; manual "Sync now" always forces. +- Health sync is only constructed/run when the active backend is `SqliteStorageService` (guarded like `SqlQueryService` already is in `main.dart`). +- The existing live `get_health_metrics` coach tool is untouched. +- Timestamps are stored as ISO8601 strings, matching every other table in this schema. + +Reference spec: `docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md`. + +--- + +### Task 1: Schema + upsert methods on `SqliteStorageService` + +**Files:** +- Modify: `workout-logger/lib/services/sqlite_storage_service.dart` +- Test: `workout-logger/test/sqlite_storage_service_test.dart` + +**Interfaces:** +- Produces (used by Task 2): `SqliteStorageService.upsertHealthSamples(String type, List samples) -> Future`, `SqliteStorageService.upsertSleepSessions(List periods) -> Future`, and the existing `getSetting`/`saveSetting` (unchanged, already public) used for sync watermarks. +- Produces (used by Task 5): tables `health_samples(id, type, timestamp, value)`, `sleep_sessions(id, start_ts, end_ts, light_min, deep_min, rem_min, awake_min)`, `sleep_stage_intervals(sleep_session_id, start_ts, end_ts, stage)`. +- Consumes: `HealthSample { DateTime time, double value }` and `SleepPeriod { DateTime start, end; int? lightMinutes, deepMinutes, remMinutes, awakeMinutes; List stageTimeline }` / `SleepStageInterval { DateTime start, end; String stage }` — all already defined in `lib/models/models.dart`. + +- [ ] **Step 1: Write the failing tests** + +Add `import 'dart:io';` to the top of `workout-logger/test/sqlite_storage_service_test.dart` (alongside the existing `dart:convert` import), and add this helper + these three tests anywhere inside `main()` (e.g. right after the existing `group('SqliteStorageService — init', ...)` block): + +```dart + Future>> rawQuery( + SqliteStorageService s, + String sql, [ + List? args, + ]) async { + final db = await openReadOnlyDatabase(s.databasePath, singleInstance: false); + final rows = await db.rawQuery(sql, args); + await db.close(); + return rows; + } + + group('SqliteStorageService — health data', () { + test('upsertHealthSamples replaces duplicates on (type, timestamp)', () async { + final t = DateTime(2026, 8, 10, 22, 30); + await storage.upsertHealthSamples('heart_rate', [HealthSample(time: t, value: 60)]); + await storage.upsertHealthSamples('heart_rate', [HealthSample(time: t, value: 65)]); + + final rows = await rawQuery( + storage, + "SELECT value FROM health_samples WHERE type = 'heart_rate'", + ); + expect(rows.length, 1); + expect(rows.first['value'], 65.0); + }); + + test('upsertSleepSessions replaces stage intervals for a re-synced session', () async { + final start = DateTime(2026, 8, 10, 23); + final end = DateTime(2026, 8, 11, 7); + + await storage.upsertSleepSessions([ + SleepPeriod( + start: start, + end: end, + lightMinutes: 200, + deepMinutes: 60, + remMinutes: 100, + awakeMinutes: 10, + stageTimeline: [ + SleepStageInterval(start: start, end: start.add(const Duration(hours: 1)), stage: 'light'), + ], + ), + ]); + + await storage.upsertSleepSessions([ + SleepPeriod( + start: start, + end: end, + lightMinutes: 190, + deepMinutes: 70, + remMinutes: 100, + awakeMinutes: 10, + stageTimeline: [ + SleepStageInterval(start: start, end: start.add(const Duration(hours: 2)), stage: 'deep'), + ], + ), + ]); + + final sessions = await rawQuery(storage, 'SELECT id, deep_min FROM sleep_sessions'); + expect(sessions.length, 1); + expect(sessions.first['deep_min'], 70); + + final intervals = await rawQuery( + storage, + 'SELECT stage FROM sleep_stage_intervals WHERE sleep_session_id = ?', + [sessions.first['id']], + ); + expect(intervals.length, 1); + expect(intervals.first['stage'], 'deep'); + }); + }); + + group('SqliteStorageService — schema upgrade', () { + test('onUpgrade adds health tables to a pre-existing v1 database', () async { + final path = + '${Directory.systemTemp.path}/sqlite_v1_upgrade_${DateTime.now().microsecondsSinceEpoch}.db'; + final v1 = await openDatabase( + path, + version: 1, + onCreate: (db, v) async { + await db.execute('''CREATE TABLE muscle_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + growth_rate REAL NOT NULL DEFAULT 0, + last_updated TEXT NOT NULL + )'''); + }, + ); + await v1.close(); + + final upgraded = SqliteStorageService(databasePathOverride: path); + await upgraded.init(); + + final tableRows = await rawQuery( + upgraded, + "SELECT name FROM sqlite_master WHERE type = 'table'", + ); + final names = tableRows.map((r) => r['name'] as String).toSet(); + expect(names, containsAll(['health_samples', 'sleep_sessions', 'sleep_stage_intervals'])); + + await File(path).delete(); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/sqlite_storage_service_test.dart` (from `workout-logger/`) +Expected: FAIL — `upsertHealthSamples`/`upsertSleepSessions` are not defined on `SqliteStorageService`, and the upgrade test fails because `health_samples` etc. don't exist yet. + +- [ ] **Step 3: Implement the schema + upsert methods** + +In `workout-logger/lib/services/sqlite_storage_service.dart`: + +Change the version constant: + +```dart + static const int _dbVersion = 2; +``` + +Add a new const list right above `_schemaStatements`, and spread it into `_schemaStatements`'s closing entries (immediately after the existing `'CREATE INDEX idx_sessions_date ON sessions(date)',` line): + +```dart + /// Added in schema v2 (health sync). Kept separate from the rest of + /// [_schemaStatements] so `onUpgrade` can run exactly these statements + /// against pre-v2 databases without re-running the full v1 DDL. + static const List _healthSchemaStatements = [ + '''CREATE TABLE health_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + timestamp TEXT NOT NULL, + value REAL NOT NULL + )''', + 'CREATE UNIQUE INDEX idx_health_samples_unique ON health_samples(type, timestamp)', + 'CREATE INDEX idx_health_samples_type_ts ON health_samples(type, timestamp)', + '''CREATE TABLE sleep_sessions ( + id TEXT PRIMARY KEY, + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + light_min INTEGER, + deep_min INTEGER, + rem_min INTEGER, + awake_min INTEGER + )''', + 'CREATE INDEX idx_sleep_sessions_start ON sleep_sessions(start_ts)', + '''CREATE TABLE sleep_stage_intervals ( + sleep_session_id TEXT NOT NULL, + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + stage TEXT NOT NULL + )''', + 'CREATE INDEX idx_sleep_stage_session ON sleep_stage_intervals(sleep_session_id)', + ]; + + static const List _schemaStatements = [ + // ...existing statements unchanged... + 'CREATE INDEX idx_sessions_date ON sessions(date)', + ..._healthSchemaStatements, + ]; +``` + +Update the `openDatabase` call inside `init()` to add `onUpgrade`: + +```dart + _db = await openDatabase( + dbPath, + version: _dbVersion, + onCreate: (db, version) async { + for (final statement in _schemaStatements) { + await db.execute(statement); + } + }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + for (final statement in _healthSchemaStatements) { + await db.execute(statement); + } + } + }, + ); +``` + +Add a new section right after `// ==================== STATS ====================` and its method (before `// ==================== EXPORT / IMPORT ====================`): + +```dart + // ==================== HEALTH DATA (coach SQL joins only) ==================== + // Written by HealthDataSyncService; never read through IStorageService — + // consumed only via the coach's run_sql_query tool. See + // docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md. + + Future upsertHealthSamples(String type, List samples) async { + if (samples.isEmpty) return; + final batch = _db.batch(); + for (final s in samples) { + batch.insert( + 'health_samples', + { + 'type': type, + 'timestamp': s.time.toIso8601String(), + 'value': s.value, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + Future upsertSleepSessions(List periods) async { + if (periods.isEmpty) return; + await _db.transaction((txn) async { + for (final p in periods) { + final id = p.start.toIso8601String(); + await txn.delete( + 'sleep_stage_intervals', + where: 'sleep_session_id = ?', + whereArgs: [id], + ); + await txn.insert( + 'sleep_sessions', + { + 'id': id, + 'start_ts': p.start.toIso8601String(), + 'end_ts': p.end.toIso8601String(), + 'light_min': p.lightMinutes, + 'deep_min': p.deepMinutes, + 'rem_min': p.remMinutes, + 'awake_min': p.awakeMinutes, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (final seg in p.stageTimeline) { + await txn.insert('sleep_stage_intervals', { + 'sleep_session_id': id, + 'start_ts': seg.start.toIso8601String(), + 'end_ts': seg.end.toIso8601String(), + 'stage': seg.stage, + }); + } + } + }); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/sqlite_storage_service_test.dart` +Expected: PASS (all tests, including the pre-existing ones in this file). + +- [ ] **Step 5: Commit** + +```bash +git add lib/services/sqlite_storage_service.dart test/sqlite_storage_service_test.dart +git commit -m "feat: add health_samples/sleep_sessions tables + upsert methods to SqliteStorageService" +``` + +--- + +### Task 2: `HealthDataSyncService` + +**Files:** +- Create: `workout-logger/lib/services/health_data_sync_service.dart` +- Test: Create `workout-logger/test/health_data_sync_service_test.dart` + +**Interfaces:** +- Consumes (from Task 1): `SqliteStorageService.upsertHealthSamples`, `SqliteStorageService.upsertSleepSessions`, `SqliteStorageService.getSetting`/`saveSetting`, `SqliteStorageService.databasePath`. +- Consumes (existing): `IHealthConnectService.readSleepSessions(DateTime, DateTime) -> Future>`, `.readHeartRateSamples(DateTime, DateTime) -> Future>`, `.readRestingHeartRate(DateTime, DateTime) -> Future>`, `.readHrvRmssd(DateTime, DateTime) -> Future>` (all in `lib/services/interfaces/health_connect_service_interface.dart`). +- Produces (used by Task 3 and Task 4): `HealthDataSyncService(IHealthConnectService hc, SqliteStorageService storage, {DateTime Function()? now})` with method `Future sync({bool force = false})`. + +- [ ] **Step 1: Write the failing tests** + +Create `workout-logger/test/health_data_sync_service_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/health_data_sync_service.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; + +class _RecordingHcService implements IHealthConnectService { + final List<({String method, DateTime from, DateTime to})> calls = []; + List heartRateSamples = const []; + List restingHrSamples = const []; + bool throwOnHeartRate = false; + + @override + Future> readSleepSessions(DateTime start, DateTime end) async { + calls.add((method: 'sleep', from: start, to: end)); + return const []; + } + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async { + calls.add((method: 'heart_rate', from: start, to: end)); + if (throwOnHeartRate) throw Exception('boom'); + return heartRateSamples; + } + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async { + calls.add((method: 'resting_heart_rate', from: start, to: end)); + return restingHrSamples; + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + calls.add((method: 'hrv_rmssd', from: start, to: end)); + return const []; + } + + @override + Future> grantedReadTypes() async => HealthReadType.values.toSet(); + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +Future>> _rawQuery( + SqliteStorageService s, + String sql, [ + List? args, +]) async { + final db = await openReadOnlyDatabase(s.databasePath, singleInstance: false); + final rows = await db.rawQuery(sql, args); + await db.close(); + return rows; +} + +void main() { + late SqliteStorageService storage; + late _RecordingHcService hc; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + storage = SqliteStorageService(databasePathOverride: inMemoryDatabasePath); + await storage.init(); + hc = _RecordingHcService(); + }); + + test('first sync backfills 90 days plus the 3-day lookback', () async { + final now = DateTime(2026, 8, 11, 9); + final service = HealthDataSyncService(hc, storage, now: () => now); + + await service.sync(); + + final sleepCall = hc.calls.firstWhere((c) => c.method == 'sleep'); + expect(sleepCall.to, now); + expect(sleepCall.from, now.subtract(const Duration(days: 93))); + }); + + test('second sync only re-fetches from watermark minus the 3-day lookback', () async { + final firstRun = DateTime(2026, 8, 1, 9); + final secondRun = DateTime(2026, 8, 11, 9); + var current = firstRun; + final service = HealthDataSyncService(hc, storage, now: () => current); + + await service.sync(force: true); + hc.calls.clear(); + current = secondRun; + await service.sync(force: true); + + final sleepCall = hc.calls.firstWhere((c) => c.method == 'sleep'); + expect(sleepCall.from, firstRun.subtract(const Duration(days: 3))); + expect(sleepCall.to, secondRun); + }); + + test('a sync within the 30-minute throttle window is skipped unless forced', () async { + final firstRun = DateTime(2026, 8, 11, 9, 0); + final soonAfter = DateTime(2026, 8, 11, 9, 10); + var current = firstRun; + final service = HealthDataSyncService(hc, storage, now: () => current); + + await service.sync(); + hc.calls.clear(); + current = soonAfter; + await service.sync(); + + expect(hc.calls, isEmpty); + }); + + test('force:true bypasses the throttle', () async { + final firstRun = DateTime(2026, 8, 11, 9, 0); + final soonAfter = DateTime(2026, 8, 11, 9, 10); + var current = firstRun; + final service = HealthDataSyncService(hc, storage, now: () => current); + + await service.sync(); + hc.calls.clear(); + current = soonAfter; + await service.sync(force: true); + + expect(hc.calls, isNotEmpty); + }); + + test('re-syncing the same sample does not duplicate rows', () async { + final now = DateTime(2026, 8, 11, 9); + hc.heartRateSamples = [HealthSample(time: DateTime(2026, 8, 10, 22), value: 62)]; + final service = HealthDataSyncService(hc, storage, now: () => now); + + await service.sync(force: true); + await service.sync(force: true); + + final rows = await _rawQuery( + storage, + "SELECT COUNT(*) AS c FROM health_samples WHERE type = 'heart_rate'", + ); + expect(rows.first['c'], 1); + }); + + test('a stream that throws does not block the others and leaves its watermark untouched', () async { + final now = DateTime(2026, 8, 11, 9); + hc.throwOnHeartRate = true; + hc.restingHrSamples = [HealthSample(time: now, value: 55)]; + final service = HealthDataSyncService(hc, storage, now: () => now); + + await service.sync(force: true); + + expect(await storage.getSetting('health_sync.heart_rate'), isNull); + expect(await storage.getSetting('health_sync.resting_heart_rate'), now.toIso8601String()); + + final rows = await _rawQuery( + storage, + "SELECT COUNT(*) AS c FROM health_samples WHERE type = 'resting_heart_rate'", + ); + expect(rows.first['c'], 1); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/health_data_sync_service_test.dart` +Expected: FAIL — `package:repforge/services/health_data_sync_service.dart` doesn't exist yet. + +- [ ] **Step 3: Implement `HealthDataSyncService`** + +Create `workout-logger/lib/services/health_data_sync_service.dart`: + +```dart +// health_data_sync_service.dart — pulls sleep + heart-rate data from Health +// Connect into SqliteStorageService's health_samples/sleep_sessions tables +// so the coach's run_sql_query tool can join them against workout data. +// See docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md. + +import 'interfaces/health_connect_service_interface.dart'; +import 'sqlite_storage_service.dart'; + +class HealthDataSyncService { + HealthDataSyncService(this._hc, this._storage, {DateTime Function()? now}) + : _now = now ?? DateTime.now; + + final IHealthConnectService _hc; + final SqliteStorageService _storage; + final DateTime Function() _now; + + static const Duration _backfillWindow = Duration(days: 90); + static const Duration _lookback = Duration(days: 3); + static const Duration _throttleWindow = Duration(minutes: 30); + + static const String _sleepWatermarkKey = 'health_sync.sleep'; + static const String _lastRunKey = 'health_sync.last_run'; + static const Map _sampleWatermarkKeys = { + 'heart_rate': 'health_sync.heart_rate', + 'resting_heart_rate': 'health_sync.resting_heart_rate', + 'hrv_rmssd': 'health_sync.hrv_rmssd', + }; + + /// Pulls any new sleep/HR data since the last sync into SQLite. Skipped if + /// the last sync ran under 30 minutes ago, unless [force] is true. Each of + /// the 4 underlying data streams fails independently and best-effort — + /// one stream throwing never blocks the others or this call. + Future sync({bool force = false}) async { + final now = _now(); + if (!force) { + final lastRunRaw = await _storage.getSetting(_lastRunKey); + final lastRun = lastRunRaw == null ? null : DateTime.tryParse(lastRunRaw); + if (lastRun != null && now.difference(lastRun) < _throttleWindow) return; + } + + await _syncSleep(now); + await _syncSamples('heart_rate', now, _hc.readHeartRateSamples); + await _syncSamples('resting_heart_rate', now, _hc.readRestingHeartRate); + await _syncSamples('hrv_rmssd', now, _hc.readHrvRmssd); + + await _storage.saveSetting(_lastRunKey, now.toIso8601String()); + } + + Future _windowStart(String watermarkKey, DateTime now) async { + final raw = await _storage.getSetting(watermarkKey); + final watermark = raw == null ? null : DateTime.tryParse(raw); + final base = watermark ?? now.subtract(_backfillWindow); + return base.subtract(_lookback); + } + + Future _syncSleep(DateTime now) async { + try { + final from = await _windowStart(_sleepWatermarkKey, now); + final periods = await _hc.readSleepSessions(from, now); + await _storage.upsertSleepSessions(periods); + await _storage.saveSetting(_sleepWatermarkKey, now.toIso8601String()); + } catch (_) { + // Best-effort; leave the watermark untouched so the next sync retries. + } + } + + Future _syncSamples( + String type, + DateTime now, + Future> Function(DateTime, DateTime) reader, + ) async { + final watermarkKey = _sampleWatermarkKeys[type]!; + try { + final from = await _windowStart(watermarkKey, now); + final samples = await reader(from, now); + await _storage.upsertHealthSamples(type, samples); + await _storage.saveSetting(watermarkKey, now.toIso8601String()); + } catch (_) { + // Best-effort; leave the watermark untouched so the next sync retries. + } + } +} +``` + +Note: `HealthSample` is used here only as a type annotation on the `reader` function parameter — it comes transitively from `interfaces/health_connect_service_interface.dart`, which imports `../../models/models.dart`. No separate models import is needed in this file. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/health_data_sync_service_test.dart` +Expected: PASS (all 6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/services/health_data_sync_service.dart test/health_data_sync_service_test.dart +git commit -m "feat: add HealthDataSyncService to pull sleep/HR data into SQLite" +``` + +--- + +### Task 3: Wire sync-on-launch into `main.dart` + +**Files:** +- Modify: `workout-logger/lib/main.dart` + +**Interfaces:** +- Consumes: `HealthDataSyncService(IHealthConnectService, SqliteStorageService, {DateTime Function()? now})` and `.sync({bool force})` from Task 2. + +- [ ] **Step 1: Add the import** + +In `workout-logger/lib/main.dart`, add near the other service imports (after `import 'services/health_connect_service.dart';`): + +```dart +import 'services/health_data_sync_service.dart'; +``` + +- [ ] **Step 2: Add the guarded static field** + +In `WorkoutLoggerApp`, add after the existing `_healthHistoryManager` field (`main.dart:131-132`): + +```dart + // Populates the SQLite health tables the coach's run_sql_query tool joins + // against workout data. Null under the pre-migration Hive fallback path — + // there's no live SQLite database file to sync into. Mirrors the + // sqlQuery ? ... : null guard used for CoachToolService below. + static final HealthDataSyncService? _healthDataSyncService = + _storageService is SqliteStorageService + ? HealthDataSyncService( + _healthConnectService, + _storageService as SqliteStorageService, + ) + : null; +``` + +- [ ] **Step 3: Provide it in the widget tree** + +In the `MultiProvider` `providers` list, add right after `Provider.value(value: _healthHistoryManager),` (`main.dart:164`): + +```dart + Provider.value(value: _healthDataSyncService), +``` + +- [ ] **Step 4: Trigger sync on app launch** + +In `_AppInitializerState._initializeApp()`, add `healthDataSync` to the synchronous provider-capture block at the top (alongside `readiness`): + +```dart + final readiness = context.read(); + final healthDataSync = context.read(); +``` + +Then, right after the existing `readiness.refresh();` fire-and-forget call, add: + +```dart + // Fire-and-forget: populates the SQLite tables run_sql_query joins + // against. No-op under the pre-migration Hive fallback (null there). + healthDataSync?.sync(); +``` + +- [ ] **Step 5: Verify with static analysis** + +Run: `flutter analyze` (from `workout-logger/`) +Expected: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add lib/main.dart +git commit -m "feat: sync health data into SQLite once per app launch" +``` + +--- + +### Task 4: "Sync now" button on the Profile screen + +**Files:** +- Modify: `workout-logger/lib/screens/widgets/profile_sections.dart` +- Modify: `workout-logger/lib/screens/profile_screen.dart` +- Modify: `workout-logger/test/test_utils/test_harness.dart` +- Test: Create `workout-logger/test/screens/widgets/profile_sections_health_sync_test.dart` + +**Interfaces:** +- Consumes: `HealthDataSyncService.sync({bool force})` from Task 2, provided via `Provider` from Task 3. +- Produces: `HealthConnectSection` gains two new required constructor params: `bool isHealthSyncLoading` and `VoidCallback? onHealthSyncNow`. + +- [ ] **Step 1: Write the failing widget tests** + +Create `workout-logger/test/screens/widgets/profile_sections_health_sync_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:repforge/screens/widgets/profile_sections.dart'; +import 'package:repforge/services/settings_provider.dart'; + +import '../../test_utils/mock_storage_service.dart'; + +void main() { + testWidgets('Sync now tile appears when readiness is enabled and invokes callback on tap', + (tester) async { + final settings = SettingsProvider(MockStorageService()); + await settings.init(); + await settings.setReadinessEnabled(true); + + var tapped = false; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HealthConnectSection( + settings: settings, + isLoading: false, + onToggle: (_) async {}, + isReadinessLoading: false, + onReadinessToggle: (_) async {}, + isHealthSyncLoading: false, + onHealthSyncNow: () => tapped = true, + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Sync coach data now'), findsOneWidget); + await tester.tap(find.text('Sync coach data now')); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('Sync now tile is hidden when readiness is disabled', (tester) async { + final settings = SettingsProvider(MockStorageService()); + await settings.init(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HealthConnectSection( + settings: settings, + isLoading: false, + onToggle: (_) async {}, + isReadinessLoading: false, + onReadinessToggle: (_) async {}, + isHealthSyncLoading: false, + onHealthSyncNow: () {}, + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Sync coach data now'), findsNothing); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/screens/widgets/profile_sections_health_sync_test.dart` +Expected: FAIL — `HealthConnectSection` has no `isHealthSyncLoading`/`onHealthSyncNow` parameters yet, and no "Sync coach data now" text exists. + +- [ ] **Step 3: Add the tile to `HealthConnectSection`** + +In `workout-logger/lib/screens/widgets/profile_sections.dart`, update the `HealthConnectSection` constructor (around line 234-248): + +```dart +class HealthConnectSection extends StatelessWidget { + const HealthConnectSection({ + super.key, + required this.settings, + required this.isLoading, + required this.onToggle, + required this.isReadinessLoading, + required this.onReadinessToggle, + required this.isHealthSyncLoading, + required this.onHealthSyncNow, + }); + + final SettingsProvider settings; + final bool isLoading; + final Future Function(bool) onToggle; + final bool isReadinessLoading; + final Future Function(bool) onReadinessToggle; + final bool isHealthSyncLoading; + final VoidCallback? onHealthSyncNow; +``` + +Then, inside `build()`, right after the closing `],\n ),` of the readiness `Row` (the block ending around line 346, immediately before the final `],\n ),\n );\n }\n}` that closes the outer `Column`/`_ProfileSection`), add: + +```dart + if (settings.readinessEnabled) ...[ + const SizedBox(height: AppSpacing.sm), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.sm), + _ActionTile( + icon: Icons.sync_rounded, + iconColor: _hcColor, + title: 'Sync coach data now', + subtitle: "Pull recent sleep & heart rate into the coach's database", + loading: isHealthSyncLoading, + onTap: onHealthSyncNow, + ), + ], +``` + +(`_ActionTile` is already defined later in this same file and used by `DataManagementSection`.) + +- [ ] **Step 4: Wire it up in `ProfileScreen`** + +In `workout-logger/lib/screens/profile_screen.dart`, add an import and state field near the existing ones: + +```dart +import '../services/health_data_sync_service.dart'; +``` + +```dart + bool _isSyncingHealthData = false; +``` + +Add a handler method near `_requestReadinessPermission`: + +```dart + Future _syncHealthDataNow() async { + setState(() => _isSyncingHealthData = true); + try { + final sync = context.read(); + if (sync == null) { + _showSnack('Health data sync is not available.', AppColors.error); + return; + } + await sync.sync(force: true); + if (mounted) _showSnack('Coach data synced!', AppColors.success); + } catch (e) { + if (mounted) _showSnack('Sync failed. Try again later.', AppColors.error); + } finally { + if (mounted) setState(() => _isSyncingHealthData = false); + } + } +``` + +Update the `HealthConnectSection(...)` call in `build()` (around `profile_screen.dart:359-377`) to pass the two new params: + +```dart + HealthConnectSection( + settings: settings, + isLoading: _isRequestingHcPermission, + onToggle: (value) async { + if (value) { + await _requestHealthConnectPermission(); + } else { + await settings.setHealthConnectEnabled(false); + } + }, + isReadinessLoading: _isRequestingReadinessPermission, + onReadinessToggle: (value) async { + if (value) { + await _requestReadinessPermission(); + } else { + await settings.setReadinessEnabled(false); + } + }, + isHealthSyncLoading: _isSyncingHealthData, + onHealthSyncNow: _isSyncingHealthData ? null : _syncHealthDataNow, + ), +``` + +- [ ] **Step 5: Keep existing widget tests passing** + +`ProfileScreen` reads `HealthDataSyncService?` via `context.read`, and `TestHarness.wrap` (used by `test/screens/profile_screen_test.dart` and others) doesn't register that provider. Provider's nullable-type lookup returns `null` when no matching provider is registered, so this works without changes — but add it explicitly for clarity. In `workout-logger/test/test_utils/test_harness.dart`, add the import: + +```dart +import 'package:repforge/services/health_data_sync_service.dart'; +``` + +and add to the `providers` list (after `Provider.value(value: const StubHcService()),`): + +```dart + Provider.value(value: null), +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `flutter test test/screens/widgets/profile_sections_health_sync_test.dart test/screens/profile_screen_test.dart test/screens/profile_screen_full_test.dart test/userflow_health_and_profile_screen_test.dart` +Expected: PASS for all four files. + +- [ ] **Step 7: Commit** + +```bash +git add lib/screens/widgets/profile_sections.dart lib/screens/profile_screen.dart test/test_utils/test_harness.dart test/screens/widgets/profile_sections_health_sync_test.dart +git commit -m "feat: add manual 'Sync coach data now' action to Profile screen" +``` + +--- + +### Task 5: Extend `run_sql_query`'s schema for the coach + +**Files:** +- Modify: `workout-logger/lib/services/ai/coach_tool_service.dart` +- Test: Create `workout-logger/test/coach_tool_service_schema_test.dart` +- Test: Modify `workout-logger/test/sql_query_service_test.dart` + +**Interfaces:** +- Consumes: the `health_samples`, `sleep_sessions`, `sleep_stage_intervals` tables from Task 1. Consumes `CoachToolService.buildTools() -> List` (existing, public) and `FunctionDeclaration.name`/`.description` (public fields from the `google_generative_ai` package) to assert on the schema text. + +- [ ] **Step 1: Write the failing tests** + +Create `workout-logger/test/coach_tool_service_schema_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/ai/sql_query_service.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; + +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + test('run_sql_query schema description includes the new health tables', () { + final storage = MockStorageService(); + final wp = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + final prm = PRManager(storage); + final tools = CoachToolService(wp, prm, sqlQuery: SqlQueryService('unused.db')); + + final decl = tools + .buildTools() + .single + .functionDeclarations! + .firstWhere((d) => d.name == 'run_sql_query'); + + expect(decl.description, contains('health_samples')); + expect(decl.description, contains('sleep_sessions')); + expect(decl.description, contains('sleep_stage_intervals')); + }); +} +``` + +This test fails before Step 3's edit (the current description has none of those table names) and passes after — it's the actual TDD-relevant assertion for this task, since the schema text is what the model reads and nothing else in the codebase asserts on it. + +Also add this regression test to `workout-logger/test/sql_query_service_test.dart`, inside `main()` (e.g. after the `'does not close the app\'s shared connection...'` test added previously), to confirm the join shape the coach will actually run works end-to-end once the tables exist: + +```dart + test('can join workouts against sleep and HR data', () async { + await seedDb.execute('''CREATE TABLE health_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + timestamp TEXT NOT NULL, + value REAL NOT NULL + )'''); + await seedDb.execute('''CREATE TABLE sleep_sessions ( + id TEXT PRIMARY KEY, + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + light_min INTEGER, + deep_min INTEGER, + rem_min INTEGER, + awake_min INTEGER + )'''); + await seedDb.insert('health_samples', { + 'type': 'resting_heart_rate', + 'timestamp': '2026-08-10T07:00:00.000', + 'value': 58.0, + }); + await seedDb.insert('sleep_sessions', { + 'id': '2026-08-09T23:00:00.000', + 'start_ts': '2026-08-09T23:00:00.000', + 'end_ts': '2026-08-10T07:00:00.000', + 'light_min': 200, + 'deep_min': 70, + 'rem_min': 90, + 'awake_min': 5, + }); + + final service = SqlQueryService(dbPath); + final result = await service.runQuery(''' + SELECT w.name AS widget_name, s.deep_min AS deep_min, h.value AS resting_hr + FROM widgets w, sleep_sessions s + JOIN health_samples h ON h.type = 'resting_heart_rate' + WHERE w.id = 1 + '''); + + expect(result['error'], isNull); + expect(result['row_count'], 1); + expect((result['rows'] as List).first, { + 'widget_name': 'foo', + 'deep_min': 70, + 'resting_hr': 58.0, + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail/pass as expected** + +Run: `flutter test test/coach_tool_service_schema_test.dart` +Expected: FAIL — the current `run_sql_query` description doesn't mention `health_samples`, `sleep_sessions`, or `sleep_stage_intervals`. + +Run: `flutter test test/sql_query_service_test.dart --plain-name "can join workouts against sleep and HR data"` +Expected: PASS already — this test only exercises the query engine against tables it creates itself, not the schema description, so it isn't failing-first. It's included as regression coverage for the join shape the coach will actually run once Step 3 tells it these tables exist. + +- [ ] **Step 3: Extend the coach's schema description** + +In `workout-logger/lib/services/ai/coach_tool_service.dart`, in `_runSqlQueryDeclaration` (around line 391-393), insert the three new table lines right after the `personal_records(...)` line and before the `'When joining tables, ...'` line: + +```dart + 'personal_records(exercise_id, best_weight, best_reps, best_volume, achieved_at)\n' + 'health_samples(id, type, timestamp, value) — type is heart_rate | ' + 'resting_heart_rate | hrv_rmssd; one row per Health Connect sample\n' + 'sleep_sessions(id, start_ts, end_ts, light_min, deep_min, rem_min, ' + 'awake_min) — one row per night, id is the session start_ts\n' + 'sleep_stage_intervals(sleep_session_id, start_ts, end_ts, stage) — ' + 'stage is deep | rem | light | awake\n' + 'When joining tables, select explicit columns with aliases (e.g. s.id AS ' + 'session_id, l.id AS log_id) instead of SELECT *, since duplicate column ' + 'names across joined tables will silently collide.\n' + 'Only SELECT/WITH statements are allowed, one statement per call.', +``` + +(Delete the old `'When joining tables, ...'` and `'Only SELECT/WITH...'` lines from their original position — they're being replaced by the block above, unchanged in content but moved after the three new lines.) + +- [ ] **Step 4: Run tests to verify everything passes** + +Run: `flutter test test/sql_query_service_test.dart test/coach_tool_service_schema_test.dart` +Expected: PASS for both files. + +- [ ] **Step 5: Commit** + +```bash +git add lib/services/ai/coach_tool_service.dart test/sql_query_service_test.dart test/coach_tool_service_schema_test.dart +git commit -m "feat: teach run_sql_query about the new health_samples/sleep_sessions tables" +``` + +--- + +### Final Verification + +- [ ] Run the full test suite: `flutter test` (from `workout-logger/`). Expected: all tests PASS, no regressions. +- [ ] Run `flutter analyze`. Expected: `No issues found!` diff --git a/docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md b/docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md new file mode 100644 index 0000000..5dcaf59 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md @@ -0,0 +1,135 @@ +# Health Data Sync (Sleep + HR) into SQLite — Design Spec + +**Date:** 2026-08-11 +**Status:** Approved +**Feature area:** Storage layer (`lib/services/`) + AI Coach SQL tool (`lib/services/ai/`) + +--- + +## 1. Problem + +The AI Coach's `run_sql_query` tool (added in `docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`) can query workouts, sets, targets, and PRs directly — but health data (sleep stages, heart rate, resting HR, HRV) is fetched live from Health Connect on every request via `HealthConnectService`/`HealthHistoryManager` and is never persisted. This means the coach cannot join health data against workout data in a single SQL query (e.g. "average sleep the night before a PR attempt" or "HR trend across the last 8 weeks of leg day sessions") — each half of the question requires a separate tool call and the model has to reconcile the join itself, unreliably. + +This spec adds three SQLite tables that mirror Health Connect data, plus a sync service that keeps them populated, so `run_sql_query` can join across workout and health data directly. + +--- + +## 2. Goal + +1. Persist sleep sessions (with stage breakdown) and HR-related samples (raw heart rate, resting heart rate, HRV RMSSD) into the same SQLite database `SqliteStorageService` already owns. +2. Keep this data reasonably fresh via sync-on-app-launch (throttled) plus a manual "Sync now" action — no background service. +3. Extend `run_sql_query`'s schema description so the coach can query and join the new tables. +4. Keep the existing live `get_health_metrics` coach tool as-is, for "right now" freshness the synced tables won't have until the next sync. + +Non-goals: no background/periodic sync (WorkManager or equivalent), no downsampling/compaction of old raw samples, no changes to `IStorageService`'s method signatures (this feature is additive on `SqliteStorageService` directly, matching how `SqlQueryService` already bypasses that interface), no UI beyond one manual sync button. + +--- + +## 3. Schema + +Added to the same database `SqliteStorageService` manages, created in `onCreate` (and via a migration step for existing installs already past `onCreate` — see §6). + +```sql +-- Raw heart rate, resting heart rate, and HRV RMSSD samples all share the +-- same {time, value} shape from Health Connect; one EAV-style table avoids +-- three near-identical tables and keeps the coach's query surface simple +-- ("WHERE type = 'heart_rate'") instead of three tables to remember. +CREATE TABLE health_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, -- 'heart_rate' | 'resting_heart_rate' | 'hrv_rmssd' + timestamp TEXT NOT NULL, -- ISO8601 + value REAL NOT NULL +); +CREATE UNIQUE INDEX idx_health_samples_unique ON health_samples(type, timestamp); +CREATE INDEX idx_health_samples_type_ts ON health_samples(type, timestamp); + +CREATE TABLE sleep_sessions ( + id TEXT PRIMARY KEY, -- synthetic: the start_ts ISO string + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + light_min INTEGER, + deep_min INTEGER, + rem_min INTEGER, + awake_min INTEGER +); +CREATE INDEX idx_sleep_sessions_start ON sleep_sessions(start_ts); + +CREATE TABLE sleep_stage_intervals ( + sleep_session_id TEXT NOT NULL REFERENCES sleep_sessions(id), + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + stage TEXT NOT NULL -- 'deep' | 'rem' | 'light' | 'awake' +); +CREATE INDEX idx_sleep_stage_session ON sleep_stage_intervals(sleep_session_id); +``` + +Sync watermarks (one ISO8601 timestamp per data stream, e.g. key `health_sync.heart_rate`) are stored as ordinary rows in the existing `settings` table — no new table needed for that. + +`sleep_sessions.id` is derived from `start_ts` so re-syncing the same session (e.g. after a Health Connect correction) is a natural upsert target, not a duplicate. + +--- + +## 4. `HealthSyncService` + +New file: `lib/services/health_sync_service.dart`. + +```dart +class HealthSyncService { + HealthSyncService(this._hc, this._db); + + final IHealthConnectService _hc; + final SqliteStorageService _db; + + Future sync({bool force = false}) async { ... } +} +``` + +- **Throttle:** skip if the most recent sync (tracked via a `health_sync.last_run` watermark) was less than 30 minutes ago, unless `force: true`. +- **Per-stream incremental fetch with look-back:** for each of `sleep`, `heart_rate`, `resting_heart_rate`, `hrv_rmssd`: read that stream's watermark from `settings` (default `now - 90 days` if absent — the agreed backfill window). Fetch from `watermark - 3 days` through `now` — the 3-day look-back re-pulls recent data even though it was already synced, to catch late corrections Health Connect or the watch itself makes to recent records (e.g. a sleep session Health Connect revises the next morning). Anything before the look-back window is assumed final and is never re-fetched. +- **Upsert:** + - `health_samples`: `INSERT OR REPLACE` keyed by the `(type, timestamp)` unique index — naturally idempotent and self-correcting. + - `sleep_sessions` / `sleep_stage_intervals`: for each `SleepPeriod` in the fetch window, delete-then-reinsert `sleep_stage_intervals` for that session id and upsert the `sleep_sessions` row — same delete/reinsert-child-rows pattern the original migration spec already uses for `sets`/`exercise_logs`. + - After all four streams succeed, advance each stream's watermark to `now` and the `last_run` throttle marker to `now`. +- **Failure handling:** any exception (permission not granted, Health Connect unavailable, one stream fails) is caught per-stream — a failed stream's watermark is left untouched so the next sync retries it, and does not block the other streams or crash the caller. Matches the existing best-effort caching posture in `HealthHistoryManager._readCachedHrDay`. + +### Wiring + +Only constructed when the active backend is `SqliteStorageService` — mirrors the existing guard in `main.dart:191-192` (`_storageService is SqliteStorageService ? SqlQueryService(...) : null`). Health data has no meaning under the pre-migration Hive fallback path. + +- `AppInitializer` calls `sync()` once after both `HealthConnectService` and `SqliteStorageService` are ready, fire-and-forget (does not block first frame). +- A "Sync now" button is added to the existing health-permissions area of the Profile screen, calling `sync(force: true)`. + +--- + +## 5. Coach SQL Tool Update + +`CoachToolService`'s embedded schema description (used by `run_sql_query`, §7 of the original migration spec) gets the three new tables appended in the same one-line-per-table/column format as the existing schema text, so the model can join them against `sessions`, `exercise_logs`, and `sets` without a separate discovery call. + +`get_health_metrics` (the existing live Health Connect tool) is unchanged — it remains the source for "right now" data that the synced tables won't have until the next app-open or manual sync. + +--- + +## 6. Migration for Existing Installs + +Existing SQLite installs (already past `onCreate`) need the three new tables added without a fresh install. `SqliteStorageService.init()` bumps `_dbVersion` and adds an `onUpgrade` step that runs the `CREATE TABLE`/`CREATE INDEX` statements from §3 if the new tables don't already exist (`CREATE TABLE IF NOT EXISTS`, safe to run unconditionally on upgrade). No data migration needed — these are brand-new tables with no prior data to carry forward; the first post-upgrade sync populates them via the normal 90-day backfill path. + +--- + +## 7. Testing + +- **`HealthSyncService`** (new test file, in-memory DB via `sqflite_common_ffi` + a fake `IHealthConnectService`): + - First sync with no prior watermark backfills the full 90-day window. + - Second sync only re-fetches from `watermark - 3 days` onward (verify the fake service receives the narrower range). + - Re-running sync is idempotent: no duplicate rows in `health_samples` or `sleep_sessions`, and changed values from a "corrected" fake response overwrite the prior row. + - A sync attempted less than 30 minutes after the last one is skipped unless `force: true`. + - An exception thrown by the fake health service for one stream doesn't propagate, doesn't advance that stream's watermark, and doesn't block the other streams from syncing. +- **`SqliteStorageService`**: extend the existing test file to cover the new upsert methods and the `onUpgrade` path (open a v-1 schema DB, run `init()`, assert the new tables exist). +- **`run_sql_query`**: extend `sql_query_service_test.dart` with a join query across `sessions`, `sets`, `sleep_sessions`, and `health_samples`, confirming the schema and join work end-to-end. + +--- + +## 8. Rollout Notes + +- No new dependencies — reuses `sqflite`, `sqflite_common_ffi` (test), and the existing `IHealthConnectService`. +- No changes to `IStorageService`, `MockStorageService`, or any manager/provider — additive on `SqliteStorageService` only, same boundary `SqlQueryService` already uses. +- `CLAUDE.md`'s "6 boxes" / schema references would benefit from a follow-up doc note once this ships, but that's out of scope here (same deferral pattern as the original migration spec, §9). diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 0991b49..05f4a83 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -3,16 +3,23 @@ // Following Dependency Inversion Principle: we create concrete implementations // here at the composition root and inject them into high-level modules. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'services/debug_log_buffer.dart'; import 'services/storage_service.dart'; +import 'services/sqlite_storage_service.dart'; +import 'services/storage_backend_resolver.dart'; +import 'services/ai/sql_query_service.dart'; import 'services/ml_service.dart'; import 'services/ai/gemini_ai_service.dart'; import 'services/ai/coach_tool_service.dart'; import 'services/health_connect_service.dart'; +import 'services/health_data_sync_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; import 'services/interfaces/health_connect_service_interface.dart'; @@ -32,6 +39,55 @@ import 'theme/a2ui_app_theme.dart'; import 'screens/home_screen.dart'; import 'screens/onboarding_screen.dart'; +/// Resolved once in main() before runApp(). Read lazily by +/// WorkoutLoggerApp._storageService's static initializer, which only runs +/// on first access (during build()) — by then this is already set. +IStorageService? _resolvedStorageService; + +/// One-time, flag-gated, reversible Hive -> SQLite cutover. See +/// docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md §6. +Future _resolveStorageBackend() async { + // Hive stays initialized here even post-cutover: ApiService reads/writes + // an installation id directly against this settings box, independent of + // IStorageService. Do not remove this unconditional init. + await Hive.initFlutter(); + final settingsBox = await Hive.openBox('settings'); + final alreadyMigrated = settingsBox.get(storageMigratedFlagKey) == 'true'; + + if (alreadyMigrated) { + final sqlite = SqliteStorageService(); + try { + await sqlite.init(); + } catch (e, st) { + debugPrint('SQLite init failed, staying on Hive: $e\n$st'); + final hiveStorage = StorageService(); + await hiveStorage.init(); + _resolvedStorageService = hiveStorage; + return; + } + _resolvedStorageService = sqlite; + return; + } + + final hiveStorage = StorageService(); + await hiveStorage.init(); + final sqliteStorage = SqliteStorageService(); + + try { + await sqliteStorage.init(); + } catch (e, st) { + debugPrint('SQLite init failed, staying on Hive: $e\n$st'); + _resolvedStorageService = hiveStorage; + return; + } + + _resolvedStorageService = await resolveStorageBackend( + hiveStorage: hiveStorage, + sqliteStorage: sqliteStorage, + alreadyMigrated: false, + ); +} + void main() async { DebugLogBuffer.attach(); WidgetsFlutterBinding.ensureInitialized(); @@ -58,13 +114,15 @@ void main() async { ), ); + await _resolveStorageBackend(); + runApp(const WorkoutLoggerApp()); } class WorkoutLoggerApp extends StatelessWidget { // Singleton instances created once at app startup // This ensures the same instances are used throughout the app lifecycle - static final IStorageService _storageService = StorageService(); + static final IStorageService _storageService = _resolvedStorageService ?? StorageService(); static final IMLService _mlService = MLService(); static final IHealthConnectService _healthConnectService = HealthConnectService(); static final ProgramManager _programManager = ProgramManager(_storageService); @@ -83,6 +141,17 @@ class WorkoutLoggerApp extends StatelessWidget { // Serves arbitrary-range sleep/HR data to the detail screens. static final HealthHistoryManager _healthHistoryManager = HealthHistoryManager(_healthConnectService, _storageService); + // Populates the SQLite health tables the coach's run_sql_query tool joins + // against workout data. Null under the pre-migration Hive fallback path — + // there's no live SQLite database file to sync into. Mirrors the + // sqlQuery ? ... : null guard used for CoachToolService below. + static final HealthDataSyncService? _healthDataSyncService = + _storageService is SqliteStorageService + ? HealthDataSyncService( + healthConnectService: _healthConnectService, + storage: _storageService as SqliteStorageService, + ) + : null; static final GeminiAiService _geminiService = GeminiAiService(storage: _storageService); static final ConversationManager _conversationManager = @@ -115,6 +184,7 @@ class WorkoutLoggerApp extends StatelessWidget { ChangeNotifierProvider.value(value: _prManager), ChangeNotifierProvider.value(value: _readinessManager), Provider.value(value: _healthHistoryManager), + Provider.value(value: _healthDataSyncService), // GeminiAiService is the single AI backend instance. It's a ChangeNotifier // (settings UI watches isConfigured/model), so it's provided as such. // Consumers that should depend on the abstraction (the coach ViewModel, @@ -134,11 +204,16 @@ class WorkoutLoggerApp extends StatelessWidget { ), ), // CoachToolService backs AI tool calls; reads from WorkoutProvider + PRManager. + // run_sql_query is only offered once the app has cut over to SQLite — + // it needs a live database file to open a read-only connection against. Provider( create: (ctx) => CoachToolService( workoutProvider: ctx.read(), prManager: ctx.read(), healthHistory: ctx.read(), + sqlQuery: _storageService is SqliteStorageService + ? SqlQueryService((_storageService as SqliteStorageService).databasePath) + : null, ), ), ], @@ -182,11 +257,16 @@ class _AppInitializerState extends State { final api = context.read(); final gemini = context.read(); final readiness = context.read(); + final healthDataSync = context.read(); try { await provider.init(); await settings.init(); - gemini.init(settings.geminiApiKey, model: settings.geminiModel); + gemini.init( + settings.geminiApiKey, + model: settings.geminiModel, + maxToolRounds: settings.geminiMaxToolRounds, + ); try { await gemini.loadUsage(); } catch (e, st) { @@ -206,6 +286,16 @@ class _AppInitializerState extends State { // so the opt-in flag is loaded; never blocks or fails app init. readiness.refresh(); + // Fire-and-forget: populates the SQLite tables run_sql_query joins + // against. No-op under the pre-migration Hive fallback (null there). + // Errors are swallowed here since main.dart discards the returned + // Future — sync() has no caller to propagate a failure to. + unawaited( + healthDataSync?.sync().catchError( + (Object e, StackTrace st) => debugPrint('healthDataSync.sync failed: $e\n$st'), + ), + ); + // Fire-and-forget analytics in background. api.sendHeartbeat(); api.trackEvent('app_open'); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index 2fb5ec6..cc734d2 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -64,6 +64,19 @@ class MuscleActivation { // ==================== Exercise ==================== +// Exercise IDs treated as bodyweight-assisted (e.g. an assisted-dip/pull-up +// machine). Computed once here so every consumer (the load panel, the input +// row, set persistence) agrees on which exercises count as "assisted". +const Set _assistedBodyweightExerciseIds = { + 'pull_ups', + 'chin_ups', + 'dips', + 'push_ups', +}; + +bool isAssistedBodyweightExercise(String? exerciseId) => + exerciseId != null && _assistedBodyweightExerciseIds.contains(exerciseId); + class Exercise { final String id; final String name; @@ -72,7 +85,7 @@ class Exercise { final bool isCustom; // User-created exercise final List? availableHandles; // Attachment/handle options e.g. ['Rope', 'Bar'] - Exercise({ + const Exercise({ required this.id, required this.name, required this.muscleActivations, @@ -250,7 +263,7 @@ class ExerciseLog { final String? notes; final String? handle; - ExerciseLog({ + const ExerciseLog({ required this.exerciseId, required this.sets, this.notes, @@ -936,12 +949,16 @@ class ChatMessage { final String role; // 'user' | 'model' final String text; final DateTime timestamp; + // Names of tools the model called (in order) while producing this reply. + // Null/empty for user messages and replies that used no tools. + final List? toolCalls; ChatMessage({ String? id, required this.role, required this.text, DateTime? timestamp, + this.toolCalls, }) : id = id ?? _uuid.v4(), timestamp = timestamp ?? DateTime.now(); @@ -950,6 +967,7 @@ class ChatMessage { 'role': role, 'text': text, 'timestamp': timestamp.toIso8601String(), + if (toolCalls != null && toolCalls!.isNotEmpty) 'toolCalls': toolCalls, }; factory ChatMessage.fromJson(Map json) => ChatMessage( @@ -957,17 +975,22 @@ class ChatMessage { role: json['role'] as String, text: json['text'] as String, timestamp: DateTime.parse(json['timestamp'] as String), + toolCalls: (json['toolCalls'] as List?)?.cast(), ); ChatMessage copyWith({ Object? role = _sentinel, Object? text = _sentinel, Object? timestamp = _sentinel, + Object? toolCalls = _sentinel, }) => ChatMessage( id: id, role: role == _sentinel ? this.role : role as String, text: text == _sentinel ? this.text : text as String, timestamp: timestamp == _sentinel ? this.timestamp : timestamp as DateTime, + toolCalls: toolCalls == _sentinel + ? this.toolCalls + : toolCalls as List?, ); } diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index a97406f..7d96c51 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -255,7 +255,10 @@ class _AiCoachViewState extends State<_AiCoachView> { itemCount: messages.length + (vm.isLoading ? 1 : 0), itemBuilder: (_, i) { if (i == messages.length) { - return _StreamingBubble(text: vm.streamingText); + return _StreamingBubble( + text: vm.streamingText, + toolCalls: vm.streamingToolCalls, + ); } return _MessageBubble(message: messages[i]); }, @@ -704,49 +707,60 @@ class _MessageBubble extends StatelessWidget { const SizedBox(width: AppSpacing.sm), ], Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, - ), - decoration: BoxDecoration( - gradient: isUser - ? const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) - : null, - color: isUser ? null : AppColors.glass3, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(AppRadius.lg), - topRight: const Radius.circular(AppRadius.lg), - bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), - bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (!isUser && (message.toolCalls?.isNotEmpty ?? false)) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: _ToolCallChips(toolNames: message.toolCalls!), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + gradient: isUser + ? const LinearGradient( + colors: [AppColors.primary, Color(0xFF5B21B6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + color: isUser ? null : AppColors.glass3, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(AppRadius.lg), + topRight: const Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), + bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), + ), + border: isUser + ? null + : Border.all(color: AppColors.glassBorder), + boxShadow: isUser + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.25), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: isUser + ? Text( + message.text, + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 14, + height: 1.55, + ), + ) + : CoachMessageContent(text: message.text), ), - border: isUser - ? null - : Border.all(color: AppColors.glassBorder), - boxShadow: isUser - ? [ - BoxShadow( - color: AppColors.primaryGlow(0.25), - blurRadius: 12, - spreadRadius: -4, - ), - ] - : null, - ), - child: isUser - ? Text( - message.text, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textPrimary, - fontSize: 14, - height: 1.55, - ), - ) - : CoachMessageContent(text: message.text), + ], ), ), ], @@ -756,8 +770,9 @@ class _MessageBubble extends StatelessWidget { } class _StreamingBubble extends StatelessWidget { - const _StreamingBubble({required this.text}); + const _StreamingBubble({required this.text, this.toolCalls = const []}); final String text; + final List toolCalls; @override Widget build(BuildContext context) { @@ -769,24 +784,35 @@ class _StreamingBubble extends StatelessWidget { _AiAvatar(), const SizedBox(width: AppSpacing.sm), Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, - ), - decoration: BoxDecoration( - color: AppColors.glass3, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(AppRadius.lg), - topRight: Radius.circular(AppRadius.lg), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(AppRadius.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (toolCalls.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: _ToolCallChips(toolNames: toolCalls, active: true), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + color: AppColors.glass3, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(AppRadius.lg), + topRight: Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(AppRadius.lg), + ), + border: Border.all(color: AppColors.glassBorder), + ), + child: text.isEmpty + ? const RFLoadingDots() + : CoachMessageContent(text: text, streaming: true), ), - border: Border.all(color: AppColors.glassBorder), - ), - child: text.isEmpty - ? const RFLoadingDots() - : CoachMessageContent(text: text, streaming: true), + ], ), ), ], @@ -795,6 +821,64 @@ class _StreamingBubble extends StatelessWidget { } } +// ── Tool-call indicator chips ───────────────────────────────────────────────── + +/// Small pill row showing which coach tools were invoked while producing a +/// reply. [active] pulses subtly to indicate a tool call is in flight. +class _ToolCallChips extends StatelessWidget { + const _ToolCallChips({required this.toolNames, this.active = false}); + final List toolNames; + final bool active; + + // Dedupe while preserving first-seen order — a tool can be called more + // than once per turn (e.g. re-checking after an update), but the chip row + // only needs to say *which* tools ran, not how many times. + List get _unique => {...toolNames}.toList(); + + String _label(String toolName) => toolName + .split('_') + .map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}') + .join(' '); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final name in _unique) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.secondary.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + active ? Icons.bolt_rounded : Icons.build_rounded, + color: AppColors.secondary, + size: 11, + ), + const SizedBox(width: 4), + Text( + _label(name), + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.secondary, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ); + } +} + /// Renders one coach reply: an A2UI dashboard when the text is a UI payload, /// otherwise Markdown. /// diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index ecfbfaa..9cdd1bc 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -17,6 +17,7 @@ import '../services/settings_provider.dart'; import '../services/api_service.dart'; import '../services/interfaces/health_connect_service_interface.dart'; import '../services/managers/readiness_manager.dart'; +import '../services/health_data_sync_service.dart'; import '../theme/app_theme.dart'; import 'widgets/profile_sections.dart'; @@ -34,6 +35,7 @@ class _ProfileScreenState extends State bool _isBackingUp = false; bool _isRequestingHcPermission = false; bool _isRequestingReadinessPermission = false; + bool _isSyncingHealthData = false; String _appVersion = ''; @override @@ -194,6 +196,25 @@ class _ProfileScreenState extends State } } + Future _syncHealthDataNow() async { + setState(() => _isSyncingHealthData = true); + try { + final sync = context.read(); + if (sync == null) { + if (mounted) { + _showSnack('Health data sync is not available.', AppColors.error); + } + return; + } + await sync.sync(force: true); + if (mounted) _showSnack('Coach data synced!', AppColors.success); + } catch (e) { + if (mounted) _showSnack('Sync failed. Try again later.', AppColors.error); + } finally { + if (mounted) setState(() => _isSyncingHealthData = false); + } + } + Future _exportToFile() async { setState(() => _isExporting = true); try { @@ -374,6 +395,8 @@ class _ProfileScreenState extends State await settings.setReadinessEnabled(false); } }, + isHealthSyncLoading: _isSyncingHealthData, + onHealthSyncNow: _isSyncingHealthData ? null : _syncHealthDataNow, ), const SizedBox(height: AppSpacing.md), DataManagementSection( diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index acc511f..23a25fb 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -7,19 +7,6 @@ import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; -// Exercise IDs treated as bodyweight-assisted (e.g. an assisted-dip/pull-up -// machine). Computed once here so the load panel and the input row never -// drift out of sync on which exercises count as "assisted". -const Set _assistedBodyweightExerciseIds = { - 'pull_ups', - 'chin_ups', - 'dips', - 'push_ups', -}; - -bool isAssistedBodyweightExercise(String? exerciseId) => - exerciseId != null && _assistedBodyweightExerciseIds.contains(exerciseId); - // ── ExerciseInputSection ────────────────────────────────────────────────────── // Renders: AI suggestion card, weight/reps inputs, dropset section, // LOG SET button, previous sets, last session info, program metadata banner. diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index d6bbee3..634dd9c 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -239,6 +239,8 @@ class HealthConnectSection extends StatelessWidget { required this.onToggle, required this.isReadinessLoading, required this.onReadinessToggle, + required this.isHealthSyncLoading, + required this.onHealthSyncNow, }); final SettingsProvider settings; @@ -246,6 +248,8 @@ class HealthConnectSection extends StatelessWidget { final Future Function(bool) onToggle; final bool isReadinessLoading; final Future Function(bool) onReadinessToggle; + final bool isHealthSyncLoading; + final VoidCallback? onHealthSyncNow; static const _hcColor = Color(0xFF00BFA5); @@ -344,6 +348,19 @@ class HealthConnectSection extends StatelessWidget { ), ], ), + if (settings.readinessEnabled) ...[ + const SizedBox(height: AppSpacing.sm), + const Divider(color: AppColors.glassBorder, height: 1), + const SizedBox(height: AppSpacing.sm), + _ActionTile( + icon: Icons.sync_rounded, + iconColor: _hcColor, + title: 'Sync coach data now', + subtitle: "Pull recent sleep & heart rate into the coach's database", + loading: isHealthSyncLoading, + onTap: isHealthSyncLoading ? null : onHealthSyncNow, + ), + ], ], ), ); @@ -772,6 +789,10 @@ class _AiSettingsSectionState extends State { bool _obscure = true; bool _saving = false; + // Live value shown while dragging the slider; null when not dragging (in + // which case the persisted settings value is shown instead). + double? _draggingMaxToolRounds; + @override void initState() { super.initState(); @@ -806,6 +827,12 @@ class _AiSettingsSectionState extends State { gemini.updateModel(modelId); } + Future _commitMaxToolRounds(int rounds) async { + final settings = context.read(); + await settings.setGeminiMaxToolRounds(rounds); + setState(() => _draggingMaxToolRounds = null); + } + @override Widget build(BuildContext context) { final gemini = context.watch(); @@ -933,6 +960,66 @@ class _AiSettingsSectionState extends State { }).toList(), ), const SizedBox(height: AppSpacing.md), + const _SectionLabel('MAX TOOL-CALL STEPS'), + const SizedBox(height: AppSpacing.sm), + Text( + 'How many tool rounds the coach can take per message before it must reply. Raise this if it stops mid-task; lower it to limit token usage.', + style: TextStyle(fontFamily: 'Geist', + color: AppColors.textFaint, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: AppSpacing.xs), + Builder(builder: (context) { + final liveValue = + _draggingMaxToolRounds ?? settings.geminiMaxToolRounds.toDouble(); + return Row( + children: [ + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: AppColors.primary, + inactiveTrackColor: AppColors.glassBorderStrong, + thumbColor: AppColors.primary, + overlayColor: AppColors.primary.withValues(alpha: 0.15), + valueIndicatorColor: AppColors.primary, + trackHeight: 3, + ), + child: Slider( + value: liveValue, + min: kMinMaxToolRounds.toDouble(), + max: kMaxMaxToolRounds.toDouble(), + divisions: kMaxMaxToolRounds - kMinMaxToolRounds, + label: '${liveValue.round()}', + onChanged: (v) { + setState(() => _draggingMaxToolRounds = v); + context.read().updateMaxToolRounds(v.round()); + }, + onChangeEnd: (v) => _commitMaxToolRounds(v.round()), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.35)), + ), + child: Text( + '${liveValue.round()}', + style: TextStyle(fontFamily: 'GeistMono', + color: AppColors.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ], + ); + }), + const SizedBox(height: AppSpacing.md), SizedBox( width: double.infinity, child: AnimatedContainer( diff --git a/workout-logger/lib/services/ai/coach_tool_service.dart b/workout-logger/lib/services/ai/coach_tool_service.dart index b377052..ce5e234 100644 --- a/workout-logger/lib/services/ai/coach_tool_service.dart +++ b/workout-logger/lib/services/ai/coach_tool_service.dart @@ -14,6 +14,7 @@ import '../../models/sleep_hr_models.dart'; import '../workout_provider.dart'; import '../managers/pr_manager.dart'; import '../managers/health_history_manager.dart'; +import 'sql_query_service.dart'; class AmbiguousMatchException implements Exception { const AmbiguousMatchException(this.candidates); @@ -24,14 +25,17 @@ class CoachToolService { final WorkoutProvider _wp; final PRManager _pr; final HealthHistoryManager? _hh; + final SqlQueryService? _sql; CoachToolService({ required WorkoutProvider workoutProvider, required PRManager prManager, HealthHistoryManager? healthHistory, + SqlQueryService? sqlQuery, }) : _wp = workoutProvider, _pr = prManager, - _hh = healthHistory; + _hh = healthHistory, + _sql = sqlQuery; /// Tool declaration for the optimizer screen's `ask_user_questions` flow. /// NOT included in the coach's tool list — only the optimizer adds it. @@ -365,9 +369,54 @@ class CoachToolService { }, ), ), + if (_sql != null) _runSqlQueryDeclaration, ]), ]; + /// Schema-aware declaration for run_sql_query — only included when a + /// SqlQueryService is wired (i.e. the app has cut over to SQLite). + FunctionDeclaration get _runSqlQueryDeclaration => FunctionDeclaration( + 'run_sql_query', + 'Run a read-only SQL SELECT query directly against the workout database ' + 'for questions the other tools cannot answer (custom joins, filters, ' + 'or aggregations). Tables:\n' + 'sessions(id, date, routine_id, duration_min, notes, hc_synced_at)\n' + 'exercise_logs(id, session_id, exercise_id, notes, handle)\n' + 'sets(id, exercise_log_id, weight, reps, is_dropset, drops_json, ' + 'time_taken, timestamp, assist_weight, extra_weight, handle)\n' + 'exercises(id, name, category, is_custom, available_handles) — custom ' + 'exercises only; built-ins are not stored here\n' + 'muscle_groups(id, name, growth_rate, last_updated)\n' + 'exercise_muscle_activations(exercise_id, muscle_group_id, activation_percentage)\n' + 'routines(id, name, created_at)\n' + 'routine_exercises(routine_id, exercise_id, position)\n' + 'targets(id, exercise_id, target_type, target_value, current_value, ' + 'estimated_completion_date, created_at, is_completed)\n' + 'personal_records(exercise_id, best_weight, best_reps, best_volume, achieved_at)\n' + 'health_samples(id, type, timestamp, value) — type is heart_rate | ' + 'resting_heart_rate | hrv_rmssd; one row per Health Connect sample\n' + 'sleep_sessions(id, start_ts, end_ts, light_min, deep_min, rem_min, ' + 'awake_min) — one row per night, id is the session start_ts\n' + 'sleep_stage_intervals(sleep_session_id, start_ts, end_ts, stage) — ' + 'stage is deep | rem | light | awake\n' + 'When joining tables, select explicit columns with aliases (e.g. s.id AS ' + 'session_id, l.id AS log_id) instead of SELECT *, since duplicate column ' + 'names across joined tables will silently collide.\n' + 'Only SELECT/WITH statements are allowed, one statement per call.', + Schema.object( + properties: { + 'query': Schema.string( + description: 'A single read-only SQL SELECT statement.', + ), + 'limit': Schema.integer( + description: 'Optional. Max rows to return (default 200, max 500).', + nullable: true, + ), + }, + requiredProperties: ['query'], + ), + ); + /// Dispatch a model function call to the matching query and return a /// JSON-serializable result map. Future> handleCall(FunctionCall call) async { @@ -400,6 +449,13 @@ class CoachToolService { return await _updateRoutine(call.args); case 'add_custom_exercise': return await _addCustomExercise(call.args); + case 'run_sql_query': + final sql = _sql; + if (sql == null) return {'error': 'SQL query tool is not available.'}; + return await sql.runQuery( + (call.args['query'] as String?) ?? '', + limit: (call.args['limit'] as num?)?.toInt(), + ); default: return {'error': 'Unknown tool: ${call.name}'}; } @@ -565,7 +621,7 @@ class CoachToolService { final xMetric = (args['x_metric'] as String?)?.trim() ?? 'sleep_hours'; final yMetric = (args['y_metric'] as String?)?.trim() ?? 'workout_volume'; final exName = (args['exercise_name'] as String?)?.trim(); - final days = (args['days'] as num?)?.toInt() ?? 60; + final days = _limitArg(args, 60, key: 'days', max: 365); final cutoff = DateTime.now().subtract(Duration(days: days)); final sessions = _wp.sessions.where((s) => !s.date.isBefore(cutoff)).toList(); @@ -606,7 +662,12 @@ class CoachToolService { final hh = _hh; if (hh != null) { - final bars = await hh.sleepBars(DateTime.now(), HealthGranularity.week); + // Week granularity only covers the last 7 days (see _getHealthMetrics), + // so the default 60-day correlation window needs month granularity or + // almost every day falls outside the fetched bars and gets no x value. + final granularity = + days <= 7 ? HealthGranularity.week : HealthGranularity.month; + final bars = await hh.sleepBars(DateTime.now(), granularity); for (final b in bars) { final key = _d(b.date); final m = dayData[key]; @@ -692,7 +753,7 @@ class CoachToolService { Map _muscleGroupVolume(Map args) { final rawGroups = (args['muscle_groups'] as List?)?.cast() ?? []; - final days = (args['days'] as num?)?.toInt() ?? 60; + final days = _limitArg(args, 60, key: 'days', max: 365); final cutoff = DateTime.now().subtract(Duration(days: days)); final allExercises = _wp.allExercises; diff --git a/workout-logger/lib/services/ai/gemini_ai_service.dart b/workout-logger/lib/services/ai/gemini_ai_service.dart index 58cdf89..1051481 100644 --- a/workout-logger/lib/services/ai/gemini_ai_service.dart +++ b/workout-logger/lib/services/ai/gemini_ai_service.dart @@ -33,12 +33,20 @@ const kGeminiModels = [ // Default to the latest GA model. const kDefaultGeminiModel = 'gemini-3.6-flash'; -// Upper bound on tool-resolution rounds per user turn, to bound runaway loops. -const int _kMaxToolRounds = 5; +// Default/minimum/maximum upper bound on tool-resolution rounds per user +// turn, to bound runaway loops. User-configurable via Profile → AI Features. +const int kDefaultMaxToolRounds = 5; +const int kMinMaxToolRounds = 3; +const int kMaxMaxToolRounds = 25; // Retry policy for transient (5xx / 429) errors. Total attempts = 1 + retries. const int _kMaxRetries = 3; +// When the server supplies an explicit retryDelay, honor it up to this many +// attempts even past _kMaxRetries — a server-specified delay is more likely +// to actually resolve the transient error than our own backoff schedule. +const int _kMaxRetriesWithServerDelay = 4; + const String _apiBase = 'https://generativelanguage.googleapis.com/v1beta/models'; @@ -135,6 +143,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { String _apiKey = ''; String _model = kDefaultGeminiModel; + int _maxToolRounds = kDefaultMaxToolRounds; // Cumulative token usage across all AI calls (persisted). int _promptTokens = 0; @@ -148,6 +157,9 @@ class GeminiAiService extends ChangeNotifier implements IAiService { @override String get currentModel => _model; + /// Upper bound on tool-resolution rounds per user turn. + int get maxToolRounds => _maxToolRounds; + /// Cumulative input (prompt) tokens billed across all AI calls. int get promptTokensUsed => _promptTokens; @@ -160,9 +172,13 @@ class GeminiAiService extends ChangeNotifier implements IAiService { /// Number of AI requests recorded. int get aiRequestCount => _requestCount; - void init(String apiKey, {String model = kDefaultGeminiModel}) { + void init(String apiKey, { + String model = kDefaultGeminiModel, + int maxToolRounds = kDefaultMaxToolRounds, + }) { _apiKey = apiKey.trim(); _model = model; + _maxToolRounds = maxToolRounds.clamp(kMinMaxToolRounds, kMaxMaxToolRounds); } /// Load persisted cumulative token usage (call once at startup). @@ -239,6 +255,11 @@ class GeminiAiService extends ChangeNotifier implements IAiService { notifyListeners(); } + void updateMaxToolRounds(int rounds) { + _maxToolRounds = rounds.clamp(kMinMaxToolRounds, kMaxMaxToolRounds); + notifyListeners(); + } + // ── Raw HTTP helpers ──────────────────────────────────────────────────────── Map _makeBody({ @@ -262,12 +283,15 @@ class GeminiAiService extends ChangeNotifier implements IAiService { }, }; - // gemini-2.5-flash predates the Gemini 3.x thinking-level enum and only - // understands the older thinkingBudget (integer token budget) shape; - // 3.x models take thinkingLevel (minimal/medium/high). Since the daily - // quota fallback chain can land on either family mid-conversation, the - // config shape must match whichever model is currently selected. - Map get _thinkingConfig => _model == 'gemini-2.5-flash' + // Every gemini-2.x model predates the Gemini 3.x thinking-level enum and + // only understands the older thinkingBudget (integer token budget) shape; + // 3.x models take thinkingLevel (minimal/medium/high). Matched by family, + // not the single 'gemini-2.5-flash' id, so a persisted legacy model id + // that isn't in kGeminiModels (e.g. from a since-removed picker entry) + // still gets the right shape instead of failing with no fallback. Since + // the daily quota fallback chain can land on either family mid-conversation, + // the config shape must match whichever model is currently selected. + Map get _thinkingConfig => _model.startsWith('gemini-2') ? {'thinkingBudget': 0} : {'thinkingLevel': 'minimal'}; @@ -311,6 +335,11 @@ class GeminiAiService extends ChangeNotifier implements IAiService { final fallback = _getFallbackModel(_model); if (fallback != null) { _model = fallback; + // gemini-2.5-flash and the 3.x family use different thinkingConfig + // shapes (see _thinkingConfig) — rebuild it for the new model so the + // retried request isn't rejected for the previous model's shape. + (body['generationConfig'] as Map)['thinkingConfig'] = + _thinkingConfig; notifyListeners(); client.close(); client = http.Client(); @@ -320,7 +349,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { final customDelay = _extractRetryDelay(err); if (_isRetryableStatus(resp.statusCode) && - (attempt < _kMaxRetries || (customDelay != null && attempt < 4))) { + (attempt < _kMaxRetries || (customDelay != null && attempt < _kMaxRetriesWithServerDelay))) { client.close(); final delay = customDelay ?? _retryBackoff(attempt); await Future.delayed(delay); @@ -363,13 +392,12 @@ class GeminiAiService extends ChangeNotifier implements IAiService { // Single-shot (non-streaming) generateContent call, with retry on 5xx/429. Future> _generate(Map body) async { - final payload = jsonEncode(body); for (var attempt = 0;; attempt++) { final uri = Uri.parse('$_apiBase/$_model:generateContent?key=$_apiKey'); final response = await http.post( uri, headers: {'Content-Type': 'application/json'}, - body: payload, + body: jsonEncode(body), ); if (response.statusCode == 200) { return jsonDecode(response.body) as Map; @@ -379,6 +407,10 @@ class GeminiAiService extends ChangeNotifier implements IAiService { final fallback = _getFallbackModel(_model); if (fallback != null) { _model = fallback; + // See the matching comment in _streamSse — the thinkingConfig shape + // must match whichever model this attempt is about to hit. + (body['generationConfig'] as Map)['thinkingConfig'] = + _thinkingConfig; notifyListeners(); continue; } @@ -386,7 +418,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { final customDelay = _extractRetryDelay(response.body); if (_isRetryableStatus(response.statusCode) && - (attempt < _kMaxRetries || (customDelay != null && attempt < 4))) { + (attempt < _kMaxRetries || (customDelay != null && attempt < _kMaxRetriesWithServerDelay))) { final delay = customDelay ?? _retryBackoff(attempt); await Future.delayed(delay); continue; @@ -437,7 +469,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { Content.text(userMessage).toJson(), ]; - for (var round = 0; round < _kMaxToolRounds; round++) { + for (var round = 0; round < _maxToolRounds; round++) { final body = _makeBody( contents: contents, system: systemPrompt, @@ -519,7 +551,7 @@ class GeminiAiService extends ChangeNotifier implements IAiService { contents.add({'role': 'user', 'parts': responseParts}); } // Exhausted the tool-round budget without a final text answer. - yield '\n\n_(Stopped after $_kMaxToolRounds tool steps — try rephrasing.)_'; + yield '\n\n_(Stopped after $_maxToolRounds tool steps — try rephrasing.)_'; } catch (e) { yield 'Error: $e'; } diff --git a/workout-logger/lib/services/ai/sql_query_service.dart b/workout-logger/lib/services/ai/sql_query_service.dart new file mode 100644 index 0000000..cf018e8 --- /dev/null +++ b/workout-logger/lib/services/ai/sql_query_service.dart @@ -0,0 +1,109 @@ +// Executes model-submitted read-only SQL against a dedicated read-only +// connection to the app's live SQLite database. Used only by the coach's +// run_sql_query tool — never the app's own read/write connection. See +// docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md §7. + +import 'package:sqflite/sqflite.dart'; + +class SqlValidationException implements Exception { + SqlValidationException(this.message); + final String message; + + @override + String toString() => message; +} + +class SqlQueryService { + SqlQueryService(this.databasePath); + + final String databasePath; + + static const _forbiddenKeywords = [ + 'INSERT', + 'UPDATE', + 'DELETE', + 'DROP', + 'ALTER', + 'CREATE', + 'ATTACH', + 'DETACH', + 'PRAGMA', + 'VACUUM', + 'REPLACE', + 'TRIGGER', + ]; + + static const _forbiddenIdentifiers = [ + 'SETTINGS', + 'SQLITE_MASTER', + 'SQLITE_TEMP_MASTER', + 'SQLITE_SCHEMA', + 'SQLITE_TEMP_SCHEMA', + 'SQLITE_DBPAGE', + 'SQLITE_STAT1', + 'SQLITE_STAT2', + 'SQLITE_STAT3', + 'SQLITE_STAT4', + ]; + + String _sanitize(String rawQuery) { + var q = rawQuery.trim(); + if (q.endsWith(';')) { + q = q.substring(0, q.length - 1).trim(); + } + if (q.contains(';')) { + throw SqlValidationException('Only a single SQL statement is allowed.'); + } + final upper = q.toUpperCase(); + if (!(upper.startsWith('SELECT') || upper.startsWith('WITH'))) { + throw SqlValidationException('Only SELECT queries are allowed.'); + } + for (final kw in _forbiddenKeywords) { + if (RegExp('\\b$kw\\b').hasMatch(upper)) { + throw SqlValidationException('Query contains a forbidden keyword: $kw'); + } + } + for (final id in _forbiddenIdentifiers) { + if (RegExp('\\b$id\\b').hasMatch(upper)) { + throw SqlValidationException('Query references a restricted table: $id'); + } + } + if (upper.contains('PRAGMA_')) { + throw SqlValidationException('Query references a restricted table: PRAGMA_*'); + } + return q; + } + + /// Runs [rawQuery] read-only and returns {'row_count', 'rows'} on success + /// or {'error': message} on any validation or execution failure. Never + /// throws — callers (the coach tool loop) always get a JSON-safe result. + Future> runQuery(String rawQuery, {int? limit}) async { + final cappedLimit = (limit ?? 200).clamp(1, 500); + + final String safeQuery; + try { + safeQuery = _sanitize(rawQuery); + } on SqlValidationException catch (e) { + return {'error': e.message}; + } + + Database? db; + try { + // singleInstance: false is required here: sqflite's default open + // helper is keyed only by path (ignoring the readOnly flag), so an + // ordinary openReadOnlyDatabase() call against the same path as the + // app's live connection just returns that shared instance. Closing + // it below would then close the app's only database connection. + db = await openReadOnlyDatabase(databasePath, singleInstance: false); + final rows = await db.rawQuery( + 'SELECT * FROM (\n$safeQuery\n) LIMIT ?', + [cappedLimit], + ); + return {'row_count': rows.length, 'rows': rows}; + } catch (e) { + return {'error': 'Query failed: $e'}; + } finally { + await db?.close(); + } + } +} diff --git a/workout-logger/lib/services/health_data_sync_service.dart b/workout-logger/lib/services/health_data_sync_service.dart new file mode 100644 index 0000000..2da95d2 --- /dev/null +++ b/workout-logger/lib/services/health_data_sync_service.dart @@ -0,0 +1,144 @@ +// health_data_sync_service.dart — pulls sleep + heart-rate data from Health +// Connect into SqliteStorageService's health_samples/sleep_sessions tables +// so the coach's run_sql_query tool can join them against workout data. +// See docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md. + +import '../models/models.dart'; +import 'interfaces/health_connect_service_interface.dart'; +import 'sqlite_storage_service.dart'; + +typedef _SampleReader = Future> Function(DateTime, DateTime); + +class _SampleStream { + const _SampleStream({ + required this.watermarkKey, + required this.readType, + required this.reader, + }); + + final String watermarkKey; + final HealthReadType readType; + final _SampleReader reader; +} + +class HealthDataSyncService { + HealthDataSyncService({ + required IHealthConnectService healthConnectService, + required SqliteStorageService storage, + DateTime Function()? now, + }) : _hc = healthConnectService, + _storage = storage, + _now = now ?? DateTime.now { + _sampleStreams = { + 'heart_rate': _SampleStream( + watermarkKey: 'health_sync.heart_rate', + readType: HealthReadType.heartRate, + reader: _hc.readHeartRateSamples, + ), + 'resting_heart_rate': _SampleStream( + watermarkKey: 'health_sync.resting_heart_rate', + readType: HealthReadType.restingHeartRate, + reader: _hc.readRestingHeartRate, + ), + 'hrv_rmssd': _SampleStream( + watermarkKey: 'health_sync.hrv_rmssd', + readType: HealthReadType.hrv, + reader: _hc.readHrvRmssd, + ), + }; + } + + final IHealthConnectService _hc; + final SqliteStorageService _storage; + final DateTime Function() _now; + + // Adding a new sample stream only needs one entry here — the watermark key, + // the permission it depends on, and the reader all live together instead of + // being repeated across separate keyed maps that could drift apart. + late final Map _sampleStreams; + + static const Duration _backfillWindow = Duration(days: 90); + static const Duration _lookback = Duration(days: 3); + static const Duration _throttleWindow = Duration(minutes: 30); + + static const String _sleepWatermarkKey = 'health_sync.sleep'; + static const String _lastRunKey = 'health_sync.last_run'; + + Future? _inFlight; + + /// Pulls any new sleep/HR data since the last sync into SQLite. Skipped if + /// the last sync ran under 30 minutes ago, unless [force] is true. Each of + /// the 4 underlying data streams fails independently and best-effort — + /// one stream throwing never blocks the others or this call. Streams whose + /// permission hasn't been granted yet are skipped entirely — their + /// watermark is left untouched so the first sync after granting permission + /// still performs the full backfill instead of resuming from a watermark + /// that was silently advanced while unauthorized. + /// + /// Concurrent calls (e.g. the launch-time sync overlapping a manual + /// "sync now" tap) share a single in-flight run instead of racing. + Future sync({bool force = false}) { + return _inFlight ??= _sync(force: force).whenComplete(() => _inFlight = null); + } + + Future _sync({required bool force}) async { + final now = _now(); + if (!force) { + final lastRunRaw = await _storage.getSetting(_lastRunKey); + final lastRun = lastRunRaw == null ? null : DateTime.tryParse(lastRunRaw); + if (lastRun != null && now.difference(lastRun) < _throttleWindow) return; + } + + final Set granted; + try { + granted = await _hc.grantedReadTypes(); + } catch (_) { + // Best-effort; leave every watermark untouched so the next sync retries. + return; + } + + if (granted.contains(HealthReadType.sleep)) { + await _syncSleep(now); + } + for (final entry in _sampleStreams.entries) { + final stream = entry.value; + if (!granted.contains(stream.readType)) continue; + await _syncSamples(type: entry.key, now: now, stream: stream); + } + + await _storage.saveSetting(_lastRunKey, now.toIso8601String()); + } + + Future _windowStart(String watermarkKey, DateTime now) async { + final raw = await _storage.getSetting(watermarkKey); + final watermark = raw == null ? null : DateTime.tryParse(raw); + final base = watermark ?? now.subtract(_backfillWindow); + return base.subtract(_lookback); + } + + Future _syncSleep(DateTime now) async { + try { + final from = await _windowStart(_sleepWatermarkKey, now); + final periods = await _hc.readSleepSessions(from, now); + await _storage.upsertSleepSessions(periods); + await _storage.saveSetting(_sleepWatermarkKey, now.toIso8601String()); + } catch (_) { + // Best-effort; leave the watermark untouched so the next sync retries. + } + } + + Future _syncSamples({ + required String type, + required DateTime now, + required _SampleStream stream, + }) async { + try { + final from = await _windowStart(stream.watermarkKey, now); + final samples = await stream.reader(from, now); + await _storage.upsertHealthSamples(type, samples); + await _storage.saveSetting(stream.watermarkKey, now.toIso8601String()); + } catch (_) { + // Best-effort; leave the watermark untouched so the next sync retries. + } + } +} diff --git a/workout-logger/lib/services/interfaces/ml_service_interface.dart b/workout-logger/lib/services/interfaces/ml_service_interface.dart index fc7478f..18c6f14 100644 --- a/workout-logger/lib/services/interfaces/ml_service_interface.dart +++ b/workout-logger/lib/services/interfaces/ml_service_interface.dart @@ -89,6 +89,7 @@ abstract class IMLService { int maxReps = 12, Map? recoveryScores, List? primaryMuscleIds, + DateTime? asOf, }); /// Get default recommendations when no history exists. diff --git a/workout-logger/lib/services/managers/pr_manager.dart b/workout-logger/lib/services/managers/pr_manager.dart index 45e0f80..b2e0248 100644 --- a/workout-logger/lib/services/managers/pr_manager.dart +++ b/workout-logger/lib/services/managers/pr_manager.dart @@ -79,7 +79,10 @@ class PRManager extends ChangeNotifier { double newBestVolume = existing?.bestVolume ?? 0; for (final set in log.sets) { - if (set.weight > newBestWeight) newBestWeight = set.weight; + // effectiveWeight, not raw weight: for assisted-bodyweight sets, weight + // stores the assist load, so a raw comparison would flag more assist + // (an easier set) as a new weight PR. + if (set.effectiveWeight > newBestWeight) newBestWeight = set.effectiveWeight; if (set.reps > newBestReps) newBestReps = set.reps; if (set.volume > newBestVolume) newBestVolume = set.volume; } diff --git a/workout-logger/lib/services/ml_service.dart b/workout-logger/lib/services/ml_service.dart index 7ec025d..e7db985 100644 --- a/workout-logger/lib/services/ml_service.dart +++ b/workout-logger/lib/services/ml_service.dart @@ -342,6 +342,14 @@ class MLService implements IMLService { static const _declineWeeklyPct = -2.0; static const _minR2ForTrendSignal = 0.2; + // Deload detection thresholds: the last session counts as a deload when its + // load drops below these fractions of the session before it. + static const _deloadWeightThreshold = 0.85; + static const _deloadVolumeThreshold = 0.70; + // How recent the last session must be for a detected deload to still count + // as "active" — see the isRecent comment below. + static const _deloadRecencyWindowDays = 21; + /// Double-progression with trend- and recovery-aware modulation. /// /// Priority order: @@ -363,13 +371,21 @@ class MLService implements IMLService { int maxReps = 12, Map? recoveryScores, List? primaryMuscleIds, + DateTime? asOf, }) { + final now = asOf ?? DateTime.now(); if (lastSession.isEmpty && (pastSessions == null || pastSessions.isEmpty)) { return []; } // Determine target reference sets and deload status based on past 3 sessions trend - List refSets = lastSession; + List refSets = lastSession.isNotEmpty + ? lastSession + : pastSessions?.firstWhere( + (session) => session.isNotEmpty, + orElse: () => const [], + ) ?? + const []; bool isPostDeloadRecovery = false; if (pastSessions != null && pastSessions.length >= 2) { @@ -391,12 +407,13 @@ class MLService implements IMLService { // misread as an active deload to recover from. final mostRecentTimestamp = s0.map((s) => s.timestamp).reduce((a, b) => a.isAfter(b) ? a : b); - final isRecent = - DateTime.now().difference(mostRecentTimestamp).inDays <= 21; + final isRecent = now.difference(mostRecentTimestamp).inDays <= + _deloadRecencyWindowDays; - // If the last session (s0) was a deload (weight < 85% of s1 or volume < 70% of s1) + // If the last session (s0) was a deload relative to the one before it if (isRecent && - ((w1 > 0 && w0 < w1 * 0.85) || (v1 > 0 && v0 < v1 * 0.70))) { + ((w1 > 0 && w0 < w1 * _deloadWeightThreshold) || + (v1 > 0 && v0 < v1 * _deloadVolumeThreshold))) { refSets = s1; isPostDeloadRecovery = true; } diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 020fc2c..539a86f 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'ai/gemini_ai_service.dart' + show kDefaultMaxToolRounds, kMinMaxToolRounds, kMaxMaxToolRounds; import 'interfaces/storage_service_interface.dart'; enum WeightUnit { kg, lbs } @@ -17,6 +19,7 @@ class SettingsProvider extends ChangeNotifier { String? _lastSeenVersion; String _geminiApiKey = ''; String _geminiModel = 'gemini-3.6-flash'; + int _geminiMaxToolRounds = kDefaultMaxToolRounds; String _weeklyInsights = ''; DateTime? _weeklyInsightsDate; bool _showAdvancedMetrics = false; @@ -32,6 +35,7 @@ class SettingsProvider extends ChangeNotifier { String? get lastSeenVersion => _lastSeenVersion; String get geminiApiKey => _geminiApiKey; String get geminiModel => _geminiModel; + int get geminiMaxToolRounds => _geminiMaxToolRounds; String get weeklyInsights => _weeklyInsights; DateTime? get weeklyInsightsDate => _weeklyInsightsDate; bool get showAdvancedMetrics => _showAdvancedMetrics; @@ -62,6 +66,9 @@ class SettingsProvider extends ChangeNotifier { _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; _geminiModel = await _storage.getSetting('geminiModel') ?? 'gemini-3.6-flash'; + final maxRounds = await _storage.getSetting('geminiMaxToolRounds'); + _geminiMaxToolRounds = + maxRounds != null ? (int.tryParse(maxRounds) ?? kDefaultMaxToolRounds) : kDefaultMaxToolRounds; _weeklyInsights = await _storage.getSetting('weeklyInsights') ?? ''; final dateStr = await _storage.getSetting('weeklyInsightsDate'); _weeklyInsightsDate = dateStr != null ? DateTime.tryParse(dateStr) : null; @@ -136,6 +143,12 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + Future setGeminiMaxToolRounds(int rounds) async { + _geminiMaxToolRounds = rounds.clamp(kMinMaxToolRounds, kMaxMaxToolRounds); + await _storage.saveSetting('geminiMaxToolRounds', _geminiMaxToolRounds.toString()); + notifyListeners(); + } + Future setGeminiApiKey(String key) async { _geminiApiKey = key.trim(); await _storage.saveSetting('geminiApiKey', _geminiApiKey); diff --git a/workout-logger/lib/services/sqlite_storage_service.dart b/workout-logger/lib/services/sqlite_storage_service.dart new file mode 100644 index 0000000..7c7019f --- /dev/null +++ b/workout-logger/lib/services/sqlite_storage_service.dart @@ -0,0 +1,1009 @@ +// SQLite-backed implementation of IStorageService — replaces Hive as the +// persistence backend. See docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md +// for the schema and migration design this implements. + +import 'dart:convert'; +import 'dart:io'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sqflite/sqflite.dart'; +import '../models/models.dart'; +import '../data/exercise_database.dart'; +import 'interfaces/storage_service_interface.dart'; + +class SqliteStorageService implements IStorageService { + SqliteStorageService({String? databasePathOverride}) + : _databasePathOverride = databasePathOverride, + _instanceId = _nextInstanceId++; + + static const String _dbName = 'repforge.db'; + static const int _dbVersion = 2; + static int _nextInstanceId = 0; + + /// Added in schema v2 (health sync). Kept separate from the rest of + /// [_schemaStatements] so `onUpgrade` can run exactly these statements + /// against pre-v2 databases without re-running the full v1 DDL. + static const List _healthSchemaStatements = [ + '''CREATE TABLE IF NOT EXISTS health_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + timestamp TEXT NOT NULL, + value REAL NOT NULL + )''', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_health_samples_unique ON health_samples(type, timestamp)', + '''CREATE TABLE IF NOT EXISTS sleep_sessions ( + id TEXT PRIMARY KEY, + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + light_min INTEGER, + deep_min INTEGER, + rem_min INTEGER, + awake_min INTEGER + )''', + 'CREATE INDEX IF NOT EXISTS idx_sleep_sessions_start ON sleep_sessions(start_ts)', + '''CREATE TABLE IF NOT EXISTS sleep_stage_intervals ( + sleep_session_id TEXT NOT NULL, + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + stage TEXT NOT NULL + )''', + 'CREATE INDEX IF NOT EXISTS idx_sleep_stage_session ON sleep_stage_intervals(sleep_session_id)', + ]; + + static const List _schemaStatements = [ + '''CREATE TABLE exercises ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + is_custom INTEGER NOT NULL DEFAULT 0, + available_handles TEXT + )''', + '''CREATE TABLE muscle_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + growth_rate REAL NOT NULL DEFAULT 0, + last_updated TEXT NOT NULL + )''', + '''CREATE TABLE exercise_muscle_activations ( + exercise_id TEXT NOT NULL, + muscle_group_id TEXT NOT NULL, + activation_percentage INTEGER NOT NULL + )''', + '''CREATE TABLE routines ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL + )''', + '''CREATE TABLE routine_exercises ( + routine_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + position INTEGER NOT NULL + )''', + '''CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + routine_id TEXT, + duration_min INTEGER NOT NULL, + notes TEXT, + hc_synced_at TEXT + )''', + '''CREATE TABLE exercise_logs ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + notes TEXT, + handle TEXT + )''', + '''CREATE TABLE sets ( + id TEXT PRIMARY KEY, + exercise_log_id TEXT NOT NULL, + weight REAL NOT NULL, + reps INTEGER NOT NULL, + is_dropset INTEGER NOT NULL DEFAULT 0, + drops_json TEXT, + time_taken INTEGER, + timestamp TEXT NOT NULL, + assist_weight REAL, + extra_weight REAL, + body_weight_at_log REAL, + handle TEXT + )''', + '''CREATE TABLE targets ( + id TEXT PRIMARY KEY, + exercise_id TEXT NOT NULL, + target_type TEXT NOT NULL, + target_value REAL NOT NULL, + current_value REAL NOT NULL DEFAULT 0, + estimated_completion_date TEXT, + created_at TEXT NOT NULL, + is_completed INTEGER NOT NULL DEFAULT 0 + )''', + '''CREATE TABLE personal_records ( + exercise_id TEXT PRIMARY KEY, + best_weight REAL NOT NULL, + best_reps INTEGER NOT NULL, + best_volume REAL NOT NULL, + achieved_at TEXT NOT NULL + )''', + '''CREATE TABLE training_programs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + total_weeks INTEGER NOT NULL, + author TEXT, + is_imported INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + phases_json TEXT NOT NULL, + weeks_json TEXT NOT NULL + )''', + '''CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'coach', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + messages_json TEXT NOT NULL + )''', + '''CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT + )''', + 'CREATE INDEX idx_sets_exercise_log ON sets(exercise_log_id)', + 'CREATE INDEX idx_exercise_logs_session ON exercise_logs(session_id)', + 'CREATE INDEX idx_exercise_logs_exercise ON exercise_logs(exercise_id)', + 'CREATE INDEX idx_sessions_date ON sessions(date)', + ..._healthSchemaStatements, + ]; + + final String? _databasePathOverride; + final int _instanceId; + late Database _db; + bool _initialized = false; + + String _appVersion = const String.fromEnvironment( + 'APP_VERSION', + defaultValue: 'unknown', + ); + + /// File path of the open database — used by SqlQueryService to open a + /// separate read-only connection for the coach's SQL tool. + String get databasePath => _db.path; + + Future close() async { + if (!_initialized) return; + await _db.close(); + _initialized = false; + } + + @override + Future init() async { + if (_initialized) return; + + try { + final packageInfo = await PackageInfo.fromPlatform(); + final version = packageInfo.version; + final buildNumber = packageInfo.buildNumber; + _appVersion = buildNumber.isNotEmpty ? '$version+$buildNumber' : version; + } catch (_) { + // Keep build-time fallback in environments without platform metadata. + } + + var dbPath = _databasePathOverride ?? '${await getDatabasesPath()}/$_dbName'; + + // For in-memory databases in tests, create unique isolated databases per instance + // to support multiple concurrent test databases. Uses temp files because sqflite FFI's + // shared-cache memory URIs don't support read-only secondary connections. + if (dbPath == ':memory:') { + dbPath = '${Directory.systemTemp.path}${Platform.pathSeparator}repforge_test_${DateTime.now().microsecondsSinceEpoch}_$_instanceId.db'; + } + + _db = await openDatabase( + dbPath, + version: _dbVersion, + onCreate: (db, version) async { + for (final statement in _schemaStatements) { + await db.execute(statement); + } + }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + for (final statement in _healthSchemaStatements) { + await db.execute(statement); + } + } + }, + ); + + final count = Sqflite.firstIntValue( + await _db.rawQuery('SELECT COUNT(*) FROM muscle_groups'), + ) ?? + 0; + if (count == 0) { + await _seedDefaultMuscleGroups(); + } + + _initialized = true; + } + + Future _seedDefaultMuscleGroups() async { + final batch = _db.batch(); + for (final mg in MuscleGroups.getAll()) { + batch.insert('muscle_groups', { + 'id': mg.id, + 'name': mg.name, + 'growth_rate': mg.growthRate, + 'last_updated': mg.lastUpdated.toIso8601String(), + }); + } + await batch.commit(noResult: true); + } + + // ==================== WORKOUT SESSIONS ==================== + + @override + Future saveWorkoutSession(WorkoutSession session) async { + await _db.transaction((txn) async { + final oldLogs = await txn.query( + 'exercise_logs', + columns: ['id'], + where: 'session_id = ?', + whereArgs: [session.id], + ); + for (final row in oldLogs) { + await txn.delete('sets', where: 'exercise_log_id = ?', whereArgs: [row['id']]); + } + await txn.delete('exercise_logs', where: 'session_id = ?', whereArgs: [session.id]); + await txn.delete('sessions', where: 'id = ?', whereArgs: [session.id]); + + await txn.insert('sessions', { + 'id': session.id, + 'date': session.date.toIso8601String(), + 'routine_id': session.routineId, + 'duration_min': session.duration, + 'notes': session.notes, + 'hc_synced_at': session.hcSyncedAt?.toIso8601String(), + }); + + for (var i = 0; i < session.exercises.length; i++) { + final log = session.exercises[i]; + final logId = '${session.id}_$i'; + await txn.insert('exercise_logs', { + 'id': logId, + 'session_id': session.id, + 'exercise_id': log.exerciseId, + 'notes': log.notes, + 'handle': log.handle, + }); + for (var j = 0; j < log.sets.length; j++) { + final set = log.sets[j]; + await txn.insert('sets', { + 'id': '${logId}_$j', + 'exercise_log_id': logId, + 'weight': set.weight, + 'reps': set.reps, + 'is_dropset': set.isDropset ? 1 : 0, + 'drops_json': set.drops == null + ? null + : jsonEncode(set.drops!.map((d) => d.toJson()).toList()), + 'time_taken': set.timeTaken, + 'timestamp': set.timestamp.toIso8601String(), + 'assist_weight': set.assistWeight, + 'extra_weight': set.extraWeight, + 'body_weight_at_log': set.bodyWeightAtLog, + 'handle': set.handle, + }); + } + } + }); + } + + Future> _loadSessions({String? where, List? whereArgs}) async { + final sessionRows = await _db.query('sessions', where: where, whereArgs: whereArgs); + final sessions = []; + for (final row in sessionRows) { + final sessionId = row['id'] as String; + final logRows = await _db.query( + 'exercise_logs', + where: 'session_id = ?', + whereArgs: [sessionId], + // rowid, not id: ids are synthetic strings like "sess1_10", and + // string ordering would sort "_10" before "_2" once a session has 10+ + // exercises. rowid preserves actual insertion order regardless of id. + orderBy: 'rowid ASC', + ); + final exerciseLogs = []; + for (final logRow in logRows) { + final logId = logRow['id'] as String; + final setRows = await _db.query( + 'sets', + where: 'exercise_log_id = ?', + whereArgs: [logId], + // Same reasoning as above — an exercise log with 10+ sets would + // otherwise be misordered by lexicographic id comparison. + orderBy: 'rowid ASC', + ); + final sets = setRows + .map((s) => WorkoutSet( + weight: (s['weight'] as num).toDouble(), + reps: s['reps'] as int, + isDropset: (s['is_dropset'] as int) == 1, + drops: s['drops_json'] == null + ? null + : (jsonDecode(s['drops_json'] as String) as List) + .map((d) => DropsetEntry.fromJson(d as Map)) + .toList(), + timeTaken: s['time_taken'] as int?, + timestamp: DateTime.parse(s['timestamp'] as String), + assistWeight: (s['assist_weight'] as num?)?.toDouble(), + extraWeight: (s['extra_weight'] as num?)?.toDouble(), + bodyWeightAtLog: (s['body_weight_at_log'] as num?)?.toDouble(), + handle: s['handle'] as String?, + )) + .toList(); + exerciseLogs.add(ExerciseLog( + exerciseId: logRow['exercise_id'] as String, + sets: sets, + notes: logRow['notes'] as String?, + handle: logRow['handle'] as String?, + )); + } + sessions.add(WorkoutSession( + id: sessionId, + date: DateTime.parse(row['date'] as String), + routineId: row['routine_id'] as String?, + exercises: exerciseLogs, + duration: row['duration_min'] as int, + notes: row['notes'] as String?, + hcSyncedAt: row['hc_synced_at'] == null + ? null + : DateTime.parse(row['hc_synced_at'] as String), + )); + } + sessions.sort((a, b) => b.date.compareTo(a.date)); + return sessions; + } + + @override + Future> getAllWorkoutSessions() => _loadSessions(); + + @override + Future getWorkoutSession(String id) async { + final result = await _loadSessions(where: 'id = ?', whereArgs: [id]); + return result.isEmpty ? null : result.first; + } + + @override + Future deleteWorkoutSession(String id) async { + await _db.transaction((txn) async { + final logRows = await txn.query( + 'exercise_logs', + columns: ['id'], + where: 'session_id = ?', + whereArgs: [id], + ); + for (final row in logRows) { + await txn.delete('sets', where: 'exercise_log_id = ?', whereArgs: [row['id']]); + } + await txn.delete('exercise_logs', where: 'session_id = ?', whereArgs: [id]); + await txn.delete('sessions', where: 'id = ?', whereArgs: [id]); + }); + } + + @override + Future> getSessionsForExercise(String exerciseId) async { + final all = await getAllWorkoutSessions(); + return all.where((s) => s.exercises.any((e) => e.exerciseId == exerciseId)).toList(); + } + + @override + Future> getSessionsInDateRange(DateTime start, DateTime end) async { + final all = await getAllWorkoutSessions(); + final lo = start.isAfter(end) ? end : start; + final hi = start.isAfter(end) ? start : end; + return all.where((s) => !s.date.isBefore(lo) && !s.date.isAfter(hi)).toList(); + } + + // ==================== ROUTINES ==================== + + @override + Future saveRoutine(Routine routine) async { + await _db.transaction((txn) async { + await txn.delete('routine_exercises', where: 'routine_id = ?', whereArgs: [routine.id]); + await txn.insert( + 'routines', + { + 'id': routine.id, + 'name': routine.name, + 'created_at': routine.createdAt.toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (var i = 0; i < routine.exerciseIds.length; i++) { + await txn.insert('routine_exercises', { + 'routine_id': routine.id, + 'exercise_id': routine.exerciseIds[i], + 'position': i, + }); + } + }); + } + + Future _loadRoutineRow(Map row) async { + final exRows = await _db.query( + 'routine_exercises', + where: 'routine_id = ?', + whereArgs: [row['id']], + orderBy: 'position ASC', + ); + return Routine( + id: row['id'] as String, + name: row['name'] as String, + exerciseIds: exRows.map((r) => r['exercise_id'] as String).toList(), + createdAt: DateTime.parse(row['created_at'] as String), + ); + } + + @override + Future> getAllRoutines() async { + final rows = await _db.query('routines'); + final result = []; + for (final row in rows) { + result.add(await _loadRoutineRow(row)); + } + return result; + } + + @override + Future getRoutine(String id) async { + final rows = await _db.query('routines', where: 'id = ?', whereArgs: [id]); + if (rows.isEmpty) return null; + return _loadRoutineRow(rows.first); + } + + @override + Future deleteRoutine(String id) async { + await _db.transaction((txn) async { + await txn.delete('routine_exercises', where: 'routine_id = ?', whereArgs: [id]); + await txn.delete('routines', where: 'id = ?', whereArgs: [id]); + }); + } + + // ==================== TARGETS ==================== + + @override + Future saveTarget(Target target) async { + await _db.insert( + 'targets', + { + 'id': target.id, + 'exercise_id': target.exerciseId, + 'target_type': target.targetType, + 'target_value': target.targetValue, + 'current_value': target.currentValue, + 'estimated_completion_date': target.estimatedCompletionDate?.toIso8601String(), + 'created_at': target.createdAt.toIso8601String(), + 'is_completed': target.isCompleted ? 1 : 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Target _targetFromRow(Map row) => Target( + id: row['id'] as String, + exerciseId: row['exercise_id'] as String, + targetType: row['target_type'] as String, + targetValue: (row['target_value'] as num).toDouble(), + currentValue: (row['current_value'] as num).toDouble(), + estimatedCompletionDate: row['estimated_completion_date'] == null + ? null + : DateTime.parse(row['estimated_completion_date'] as String), + createdAt: DateTime.parse(row['created_at'] as String), + isCompleted: (row['is_completed'] as int) == 1, + ); + + @override + Future> getAllTargets() async { + final rows = await _db.query('targets'); + return rows.map(_targetFromRow).toList(); + } + + @override + Future getTarget(String id) async { + final rows = await _db.query('targets', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _targetFromRow(rows.first); + } + + @override + Future deleteTarget(String id) async { + await _db.delete('targets', where: 'id = ?', whereArgs: [id]); + } + + @override + Future> getTargetsForExercise(String exerciseId) async { + final rows = await _db.query('targets', where: 'exercise_id = ?', whereArgs: [exerciseId]); + return rows.map(_targetFromRow).toList(); + } + + // ==================== MUSCLE GROUPS ==================== + + @override + Future updateMuscleGroupGrowthRate(String muscleGroupId, double rate) async { + await _db.update( + 'muscle_groups', + {'growth_rate': rate, 'last_updated': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [muscleGroupId], + ); + } + + MuscleGroup _muscleGroupFromRow(Map row) => MuscleGroup( + id: row['id'] as String, + name: row['name'] as String, + growthRate: (row['growth_rate'] as num).toDouble(), + lastUpdated: DateTime.parse(row['last_updated'] as String), + ); + + @override + Future> getAllMuscleGroups() async { + final rows = await _db.query('muscle_groups'); + return rows.map(_muscleGroupFromRow).toList(); + } + + @override + Future getMuscleGroup(String id) async { + final rows = await _db.query('muscle_groups', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _muscleGroupFromRow(rows.first); + } + + // ==================== CUSTOM EXERCISES ==================== + + @override + Future saveCustomExercise(Exercise exercise) async { + await _db.transaction((txn) async { + await txn.delete('exercise_muscle_activations', where: 'exercise_id = ?', whereArgs: [exercise.id]); + await txn.insert( + 'exercises', + { + 'id': exercise.id, + 'name': exercise.name, + 'category': exercise.category, + 'is_custom': 1, + 'available_handles': + exercise.availableHandles == null ? null : jsonEncode(exercise.availableHandles), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (final ma in exercise.muscleActivations) { + await txn.insert('exercise_muscle_activations', { + 'exercise_id': exercise.id, + 'muscle_group_id': ma.muscleGroupId, + 'activation_percentage': ma.activationPercentage, + }); + } + }); + } + + Future _loadCustomExerciseRow(Map row) async { + final activations = await _db.query( + 'exercise_muscle_activations', + where: 'exercise_id = ?', + whereArgs: [row['id']], + ); + return Exercise( + id: row['id'] as String, + name: row['name'] as String, + category: row['category'] as String, + isCustom: true, + availableHandles: row['available_handles'] == null + ? null + : (jsonDecode(row['available_handles'] as String) as List).cast(), + muscleActivations: activations + .map((a) => MuscleActivation( + muscleGroupId: a['muscle_group_id'] as String, + activationPercentage: a['activation_percentage'] as int, + )) + .toList(), + ); + } + + @override + Future> getCustomExercises() async { + final rows = await _db.query('exercises', where: 'is_custom = 1'); + final result = []; + for (final row in rows) { + result.add(await _loadCustomExerciseRow(row)); + } + return result; + } + + @override + Future deleteCustomExercise(String id) async { + await _db.transaction((txn) async { + await txn.delete('exercise_muscle_activations', where: 'exercise_id = ?', whereArgs: [id]); + await txn.delete('exercises', where: 'id = ?', whereArgs: [id]); + }); + } + + @override + Future> getAllExercises() async { + final builtIn = ExerciseDatabase.getAll(); + final custom = await getCustomExercises(); + return [...builtIn, ...custom]; + } + + @override + Future getExercise(String id) async { + final builtIn = ExerciseDatabase.getById(id); + if (builtIn != null) return builtIn; + final rows = await _db.query('exercises', where: 'id = ?', whereArgs: [id]); + if (rows.isEmpty) return null; + return _loadCustomExerciseRow(rows.first); + } + + // ==================== SETTINGS ==================== + + @override + Future saveSetting(String key, String value) async { + await _db.insert('settings', {'key': key, 'value': value}, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + @override + Future getSetting(String key) async { + final rows = await _db.query('settings', where: 'key = ?', whereArgs: [key]); + return rows.isEmpty ? null : rows.first['value'] as String?; + } + + // ==================== TRAINING PROGRAMS ==================== + + @override + Future saveTrainingProgram(TrainingProgram program) async { + await _db.insert( + 'training_programs', + { + 'id': program.id, + 'name': program.name, + 'description': program.description, + 'total_weeks': program.totalWeeks, + 'author': program.author, + 'is_imported': program.isImported ? 1 : 0, + 'created_at': program.createdAt.toIso8601String(), + 'phases_json': jsonEncode(program.phases.map((p) => p.toJson()).toList()), + 'weeks_json': jsonEncode(program.weeks.map((w) => w.toJson()).toList()), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + TrainingProgram _programFromRow(Map row) => TrainingProgram( + id: row['id'] as String, + name: row['name'] as String, + description: row['description'] as String?, + totalWeeks: row['total_weeks'] as int, + phases: (jsonDecode(row['phases_json'] as String) as List) + .map((p) => TrainingPhase.fromJson(p as Map)) + .toList(), + weeks: (jsonDecode(row['weeks_json'] as String) as List) + .map((w) => ProgramWeek.fromJson(w as Map)) + .toList(), + author: row['author'] as String?, + isImported: (row['is_imported'] as int) == 1, + createdAt: DateTime.parse(row['created_at'] as String), + ); + + @override + Future> getAllTrainingPrograms() async { + final rows = await _db.query('training_programs', orderBy: 'created_at DESC'); + return rows.map(_programFromRow).toList(); + } + + @override + Future getTrainingProgram(String id) async { + final rows = await _db.query('training_programs', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _programFromRow(rows.first); + } + + @override + Future deleteTrainingProgram(String id) async { + await _db.delete('training_programs', where: 'id = ?', whereArgs: [id]); + } + + // ==================== PERSONAL RECORDS ==================== + + @override + Future savePersonalRecord(PersonalRecord record) async { + await _db.insert( + 'personal_records', + { + 'exercise_id': record.exerciseId, + 'best_weight': record.bestWeight, + 'best_reps': record.bestReps, + 'best_volume': record.bestVolume, + 'achieved_at': record.achievedAt.toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + PersonalRecord _prFromRow(Map row) => PersonalRecord( + exerciseId: row['exercise_id'] as String, + bestWeight: (row['best_weight'] as num).toDouble(), + bestReps: row['best_reps'] as int, + bestVolume: (row['best_volume'] as num).toDouble(), + achievedAt: DateTime.parse(row['achieved_at'] as String), + ); + + @override + Future getPersonalRecord(String exerciseId) async { + final rows = await _db.query('personal_records', where: 'exercise_id = ?', whereArgs: [exerciseId]); + return rows.isEmpty ? null : _prFromRow(rows.first); + } + + @override + Future> getAllPersonalRecords() async { + final rows = await _db.query('personal_records'); + return rows.map(_prFromRow).toList(); + } + + // ==================== AI CONVERSATIONS ==================== + + @override + Future saveConversation(Conversation conversation) async { + await _db.insert( + 'conversations', + { + 'id': conversation.id, + 'title': conversation.title, + 'kind': conversation.kind, + 'created_at': conversation.createdAt.toIso8601String(), + 'updated_at': conversation.updatedAt.toIso8601String(), + 'messages_json': jsonEncode(conversation.messages.map((m) => m.toJson()).toList()), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Conversation _conversationFromRow(Map row) => Conversation( + id: row['id'] as String, + title: row['title'] as String, + kind: row['kind'] as String, + createdAt: DateTime.parse(row['created_at'] as String), + updatedAt: DateTime.parse(row['updated_at'] as String), + messages: (jsonDecode(row['messages_json'] as String) as List) + .map((m) => ChatMessage.fromJson(m as Map)) + .toList(), + ); + + @override + Future> getAllConversations() async { + final rows = await _db.query('conversations', orderBy: 'updated_at DESC'); + return rows.map(_conversationFromRow).toList(); + } + + @override + Future getConversation(String id) async { + final rows = await _db.query('conversations', where: 'id = ?', whereArgs: [id]); + return rows.isEmpty ? null : _conversationFromRow(rows.first); + } + + @override + Future deleteConversation(String id) async { + await _db.delete('conversations', where: 'id = ?', whereArgs: [id]); + } + + // ==================== STATS ==================== + + @override + Future> getQuickStats() async { + final sessions = await getAllWorkoutSessions(); + final now = DateTime.now(); + final weekAgo = now.subtract(const Duration(days: 7)); + final weekSessions = sessions.where((s) => s.date.isAfter(weekAgo)).toList(); + + double weeklyVolume = 0; + int exercisesCompleted = 0; + for (final session in weekSessions) { + weeklyVolume += session.totalVolume; + exercisesCompleted += session.exercises.length; + } + + return { + 'totalWorkouts': sessions.length, + 'weeklyWorkouts': weekSessions.length, + 'weeklyVolume': weeklyVolume, + 'exercisesThisWeek': exercisesCompleted, + }; + } + + // ==================== HEALTH DATA (coach SQL joins only) ==================== + // Written by HealthDataSyncService; never read through IStorageService — + // consumed only via the coach's run_sql_query tool. See + // docs/superpowers/specs/2026-08-11-health-data-sync-and-coach-sql-design.md. + + Future upsertHealthSamples(String type, List samples) async { + if (samples.isEmpty) return; + final batch = _db.batch(); + for (final s in samples) { + batch.insert( + 'health_samples', + { + 'type': type, + 'timestamp': s.time.toLocal().toIso8601String(), + 'value': s.value, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + Future upsertSleepSessions(List periods) async { + if (periods.isEmpty) return; + await _db.transaction((txn) async { + for (final p in periods) { + final id = p.start.toLocal().toIso8601String(); + await txn.delete( + 'sleep_stage_intervals', + where: 'sleep_session_id = ?', + whereArgs: [id], + ); + await txn.insert( + 'sleep_sessions', + { + 'id': id, + 'start_ts': p.start.toLocal().toIso8601String(), + 'end_ts': p.end.toLocal().toIso8601String(), + 'light_min': p.lightMinutes, + 'deep_min': p.deepMinutes, + 'rem_min': p.remMinutes, + 'awake_min': p.awakeMinutes, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (final seg in p.stageTimeline) { + await txn.insert('sleep_stage_intervals', { + 'sleep_session_id': id, + 'start_ts': seg.start.toLocal().toIso8601String(), + 'end_ts': seg.end.toLocal().toIso8601String(), + 'stage': seg.stage, + }); + } + } + }); + } + + // ==================== EXPORT / IMPORT ==================== + + Map? _normalizeImportItem(dynamic item) { + if (item is Map) return item; + if (item is Map) return Map.from(item); + if (item is String) { + try { + final decoded = jsonDecode(item); + if (decoded is Map) return Map.from(decoded); + } catch (_) { + return null; + } + } + return null; + } + + @override + Future exportAllData() async { + final sessions = await getAllWorkoutSessions(); + final routines = await getAllRoutines(); + final targets = await getAllTargets(); + final muscleGroups = await getAllMuscleGroups(); + final customExercises = await getCustomExercises(); + final conversations = await getAllConversations(); + final settingsRows = await _db.query('settings'); + final settingsMap = { + for (final row in settingsRows) + if (row['value'] != null) row['key'] as String: row['value'] as String, + }; + + final data = { + 'sessions': sessions.map((s) => s.toJson()).toList(), + 'routines': routines.map((r) => r.toJson()).toList(), + 'targets': targets.map((t) => t.toJson()).toList(), + 'muscleGroups': muscleGroups.map((m) => m.toJson()).toList(), + 'customExercises': customExercises.map((e) => e.toJson()).toList(), + 'conversations': conversations.map((c) => c.toJson()).toList(), + 'settings': settingsMap, + 'exportDate': DateTime.now().toIso8601String(), + 'appVersion': _appVersion, + }; + return jsonEncode(data); + } + + @override + Future importData(String jsonData) async { + final data = jsonDecode(jsonData) as Map; + + final sessions = data['sessions']; + if (sessions is List) { + for (final item in sessions) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final session = WorkoutSession.fromJson(map); + if (await getWorkoutSession(session.id) == null) { + await saveWorkoutSession(session); + } + } + } + + final routines = data['routines']; + if (routines is List) { + for (final item in routines) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final routine = Routine.fromJson(map); + if (await getRoutine(routine.id) == null) { + await saveRoutine(routine); + } + } + } + + final targets = data['targets']; + if (targets is List) { + for (final item in targets) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final target = Target.fromJson(map); + if (await getTarget(target.id) == null) { + await saveTarget(target); + } + } + } + + final muscleGroups = data['muscleGroups']; + if (muscleGroups is List) { + for (final item in muscleGroups) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final mg = MuscleGroup.fromJson(map); + if (await getMuscleGroup(mg.id) == null) { + await _db.insert('muscle_groups', { + 'id': mg.id, + 'name': mg.name, + 'growth_rate': mg.growthRate, + 'last_updated': mg.lastUpdated.toIso8601String(), + }); + } + } + } + + final customExercises = data['customExercises']; + if (customExercises is List) { + for (final item in customExercises) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final exercise = Exercise.fromJson(map); + final rows = await _db.query('exercises', where: 'id = ?', whereArgs: [exercise.id]); + if (rows.isEmpty) { + await saveCustomExercise(exercise); + } + } + } + + if (data['settings'] is Map) { + final settings = data['settings'] as Map; + for (final entry in settings.entries) { + if (await getSetting(entry.key) == null) { + await saveSetting(entry.key, entry.value.toString()); + } + } + } + + final conversations = data['conversations']; + if (conversations is List) { + for (final item in conversations) { + final map = _normalizeImportItem(item); + if (map == null) continue; + final conversation = Conversation.fromJson(map); + if (await getConversation(conversation.id) == null) { + await saveConversation(conversation); + } + } + } + } +} diff --git a/workout-logger/lib/services/storage_backend_resolver.dart b/workout-logger/lib/services/storage_backend_resolver.dart new file mode 100644 index 0000000..2af4e47 --- /dev/null +++ b/workout-logger/lib/services/storage_backend_resolver.dart @@ -0,0 +1,37 @@ +// Decides which storage backend the app should use: SQLite if already +// migrated, otherwise runs the one-time migration and falls back to Hive +// on any failure. Pure decision logic, factored out of main.dart's +// _resolveStorageBackend so it's directly testable without booting Flutter. + +import 'package:flutter/foundation.dart'; + +import 'interfaces/storage_service_interface.dart'; +import 'storage_service.dart'; +import 'sqlite_storage_service.dart'; +import 'storage_migration_service.dart'; + +const storageMigratedFlagKey = 'storage_migrated_v1'; + +/// Given the already-initialized Hive and SQLite storage instances and +/// whether the migration flag was already set, decides which backend to +/// use — running the one-time migration and writing the flag on success, +/// or falling back to Hive on any failure. Does not call init() on either +/// argument; the caller is responsible for that. +Future resolveStorageBackend({ + required StorageService hiveStorage, + required SqliteStorageService sqliteStorage, + required bool alreadyMigrated, +}) async { + if (alreadyMigrated) { + return sqliteStorage; + } + + try { + await StorageMigrationService(hiveStorage, sqliteStorage).migrate(); + await hiveStorage.saveSetting(storageMigratedFlagKey, 'true'); + return sqliteStorage; + } catch (e, st) { + debugPrint('Storage migration to SQLite failed, staying on Hive: $e\n$st'); + return hiveStorage; + } +} diff --git a/workout-logger/lib/services/storage_migration_service.dart b/workout-logger/lib/services/storage_migration_service.dart new file mode 100644 index 0000000..695d251 --- /dev/null +++ b/workout-logger/lib/services/storage_migration_service.dart @@ -0,0 +1,47 @@ +// One-time migration from the Hive-backed StorageService to +// SqliteStorageService. Reads exclusively through StorageService's existing, +// already-correct read methods; writes exclusively through +// SqliteStorageService's write methods. Throws on any failure — the caller +// (main.dart) decides whether to fall back to Hive. See +// docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md §6. + +import 'storage_service.dart'; +import 'sqlite_storage_service.dart'; + +class StorageMigrationService { + StorageMigrationService(this._source, this._target); + + final StorageService _source; + final SqliteStorageService _target; + + Future migrate() async { + for (final session in await _source.getAllWorkoutSessions()) { + await _target.saveWorkoutSession(session); + } + for (final routine in await _source.getAllRoutines()) { + await _target.saveRoutine(routine); + } + for (final target in await _source.getAllTargets()) { + await _target.saveTarget(target); + } + for (final mg in await _source.getAllMuscleGroups()) { + await _target.updateMuscleGroupGrowthRate(mg.id, mg.growthRate); + } + for (final exercise in await _source.getCustomExercises()) { + await _target.saveCustomExercise(exercise); + } + for (final record in await _source.getAllPersonalRecords()) { + await _target.savePersonalRecord(record); + } + for (final program in await _source.getAllTrainingPrograms()) { + await _target.saveTrainingProgram(program); + } + for (final conversation in await _source.getAllConversations()) { + await _target.saveConversation(conversation); + } + final settings = await _source.getAllSettingsForMigration(); + for (final entry in settings.entries) { + await _target.saveSetting(entry.key, entry.value); + } + } +} diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 873de63..ac5c284 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -308,6 +308,18 @@ class StorageService implements IStorageService { return _settingsBoxInstance.get(key); } + /// Every stored setting key/value. Used only by [StorageMigrationService] + /// to migrate the settings box to the SQLite backend — not part of + /// [IStorageService] since no other consumer needs to enumerate all keys. + Future> getAllSettingsForMigration() async { + final map = {}; + for (final key in _settingsBoxInstance.keys) { + final value = _settingsBoxInstance.get(key); + if (value != null) map[key as String] = value; + } + return map; + } + // ==================== EXPORT / IMPORT ==================== dynamic _normalizeExportValue(dynamic value) { @@ -344,14 +356,7 @@ class StorageService implements IStorageService { @override Future exportAllData() async { - // Collect settings as a map - final settingsMap = {}; - for (final key in _settingsBoxInstance.keys) { - final value = _settingsBoxInstance.get(key); - if (value != null) { - settingsMap[key as String] = value; - } - } + final settingsMap = await getAllSettingsForMigration(); final data = { 'sessions': _sessionsBox.values diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 52b812c..8d3ca62 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -549,6 +549,7 @@ class WorkoutProvider extends ChangeNotifier { exerciseId: currentLog.exerciseId, sets: newSets, notes: currentLog.notes, + handle: currentLog.handle, ); notifyListeners(); unawaited(_persistDraft()); @@ -691,6 +692,7 @@ class WorkoutProvider extends ChangeNotifier { String? handle, int limit = 3, }) { + if (limit <= 0) return const >[]; final sortedSessions = [..._sessions]..sort((a, b) => b.date.compareTo(a.date)); final useHandle = handle != null && handle.isNotEmpty; @@ -711,6 +713,7 @@ class WorkoutProvider extends ChangeNotifier { if (useHandle) { final exact = collect((exLog) => exLog.handle == handle); if (exact.isNotEmpty) return exact; + return collect((exLog) => exLog.handle == null || exLog.handle!.isEmpty); } return collect((_) => true); } @@ -735,6 +738,7 @@ class WorkoutProvider extends ChangeNotifier { if (useHandle) { final exact = find((exLog) => exLog.handle == handle); if (exact != null) return exact; + return find((exLog) => exLog.handle == null || exLog.handle!.isEmpty); } return find((_) => true); } diff --git a/workout-logger/lib/viewmodels/ai_coach_view_model.dart b/workout-logger/lib/viewmodels/ai_coach_view_model.dart index 036154c..bf449b8 100644 --- a/workout-logger/lib/viewmodels/ai_coach_view_model.dart +++ b/workout-logger/lib/viewmodels/ai_coach_view_model.dart @@ -5,7 +5,8 @@ // persists each turn through ConversationManager. Exposes immutable state. import 'package:flutter/foundation.dart'; -import 'package:google_generative_ai/google_generative_ai.dart' show Content, TextPart; +import 'package:google_generative_ai/google_generative_ai.dart' + show Content, TextPart, FunctionCall; import '../models/models.dart'; import '../services/interfaces/ai_service_interface.dart'; @@ -22,6 +23,7 @@ class AiCoachViewModel extends ChangeNotifier { bool _loading = false; String _streamingText = ''; + final List _streamingToolCalls = []; AiCoachViewModel({ required IAiService ai, @@ -47,6 +49,8 @@ class AiCoachViewModel extends ChangeNotifier { bool get isConfigured => _ai.isConfigured; bool get isLoading => _loading; String get streamingText => _streamingText; + // Tool names invoked so far for the in-flight reply, in call order. + List get streamingToolCalls => List.unmodifiable(_streamingToolCalls); List get messages => _conversations.activeMessages; List get conversations => _conversations.conversations; String? get activeConversationId => _conversations.active?.id; @@ -80,6 +84,7 @@ class AiCoachViewModel extends ChangeNotifier { _loading = true; _streamingText = ''; + _streamingToolCalls.clear(); notifyListeners(); // Persist the user message first; history is derived from the store. @@ -97,7 +102,7 @@ class AiCoachViewModel extends ChangeNotifier { systemPrompt: systemPrompt, history: history, tools: _coachTools.buildTools(), - onToolCall: _coachTools.handleCall, + onToolCall: _recordAndDispatch, )) { buffer.write(chunk); _streamingText = buffer.toString(); @@ -106,7 +111,13 @@ class AiCoachViewModel extends ChangeNotifier { final reply = buffer.toString().trim(); if (reply.isNotEmpty) { await _conversations.appendMessage( - ChatMessage(role: 'model', text: reply), + ChatMessage( + role: 'model', + text: reply, + toolCalls: _streamingToolCalls.isEmpty + ? null + : List.of(_streamingToolCalls), + ), ); } } catch (e) { @@ -119,11 +130,19 @@ class AiCoachViewModel extends ChangeNotifier { } } finally { _streamingText = ''; + _streamingToolCalls.clear(); _loading = false; notifyListeners(); } } + // Records the tool name for UI display, then delegates to the real handler. + Future> _recordAndDispatch(FunctionCall call) async { + _streamingToolCalls.add(call.name); + notifyListeners(); + return _coachTools.handleCall(call); + } + // ── Internals ────────────────────────────────────────────────────────────── // Static prompt — live data is fetched by the model via the coach tools, diff --git a/workout-logger/pubspec.lock b/workout-logger/pubspec.lock index 24bd7ac..1feada5 100644 --- a/workout-logger/pubspec.lock +++ b/workout-logger/pubspec.lock @@ -568,6 +568,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.7.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" nested: dependency: transitive description: @@ -797,6 +805,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" + url: "https://pub.dev" + source: hosted + version: "3.5.1" stack_trace: dependency: transitive description: @@ -829,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" term_glyph: dependency: transitive description: @@ -1006,5 +1078,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.4 <4.0.0" + dart: ">=3.12.0 <4.0.0" flutter: "3.44.8" diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index c25c3a2..95f8881 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -39,7 +39,10 @@ dependencies: # Local storage hive: ^2.2.3 hive_flutter: ^1.1.0 - + + # SQLite persistence (replacing Hive) + sqflite: ^2.4.2 + # State management provider: ^6.1.1 @@ -80,6 +83,9 @@ dev_dependencies: mockito: ^5.4.4 build_runner: ^2.4.8 + # sqflite testing on the Dart VM (flutter test has no platform binding) + sqflite_common_ffi: ^2.3.4+4 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/workout-logger/scripts/test_gemini_api.py b/workout-logger/scripts/test_gemini_api.py index 13a8f10..177b89b 100644 --- a/workout-logger/scripts/test_gemini_api.py +++ b/workout-logger/scripts/test_gemini_api.py @@ -46,7 +46,7 @@ def extract_retry_delay(body_str: str) -> float | None: delay_str = str(item["retryDelay"]).replace("s", "").strip() val = float(delay_str) if val > 0: - return val + 0.35 + return min(max(val + 0.35, 0.5), 45.0) # 2. Regex search in error.message (e.g. "Please retry in 23.690750876s.") msg = err.get("message", "") if isinstance(msg, str): @@ -54,7 +54,7 @@ def extract_retry_delay(body_str: str) -> float | None: if match: val = float(match.group(1)) if val > 0: - return val + 0.35 + return min(max(val + 0.35, 0.5), 45.0) except Exception: pass return None @@ -253,6 +253,9 @@ def main() -> None: sys.exit(0) raise e candidates = res1.get("candidates", []) + if not candidates: + print(f"[!] No candidates returned. Raw response:\n{json.dumps(res1)[:500]}") + sys.exit(1) first_cand = candidates[0] model_content = first_cand.get("content", {}) raw_parts = model_content.get("parts", []) diff --git a/workout-logger/test/coach_tool_service_schema_test.dart b/workout-logger/test/coach_tool_service_schema_test.dart new file mode 100644 index 0000000..1256dd5 --- /dev/null +++ b/workout-logger/test/coach_tool_service_schema_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/ai/sql_query_service.dart'; +import 'package:repforge/services/managers/pr_manager.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/workout_provider.dart'; + +import 'test_utils/mock_ml_service.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + test('run_sql_query schema description includes the new health tables', () { + final storage = MockStorageService(); + final wp = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + final prm = PRManager(storage); + final tools = CoachToolService( + workoutProvider: wp, + prManager: prm, + sqlQuery: SqlQueryService('unused.db'), + ); + + final decl = tools + .buildTools() + .single + .functionDeclarations! + .firstWhere((d) => d.name == 'run_sql_query'); + + expect(decl.description, contains('health_samples')); + expect(decl.description, contains('sleep_sessions')); + expect(decl.description, contains('sleep_stage_intervals')); + }); +} diff --git a/workout-logger/test/coach_tool_service_test.dart b/workout-logger/test/coach_tool_service_test.dart index fbde20f..b76b86d 100644 --- a/workout-logger/test/coach_tool_service_test.dart +++ b/workout-logger/test/coach_tool_service_test.dart @@ -1,13 +1,18 @@ // Unit tests for CoachToolService — each tool returns expected JSON shapes, // backed by a seeded WorkoutProvider + PRManager. +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:google_generative_ai/google_generative_ai.dart' show FunctionCall; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/services/workout_provider.dart'; import 'package:repforge/services/managers/program_manager.dart'; import 'package:repforge/services/managers/pr_manager.dart'; import 'package:repforge/services/ai/coach_tool_service.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; +import 'package:repforge/services/ai/sql_query_service.dart'; import 'test_utils/mock_storage_service.dart'; void main() { @@ -62,6 +67,61 @@ void main() { tools = CoachToolService(workoutProvider: provider, prManager: pr); }); + group('run_sql_query', () { + late String dbPath; + late SqliteStorageService sqliteStorage; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + dbPath = '${Directory.systemTemp.path}/coach_sql_test_${DateTime.now().microsecondsSinceEpoch}.db'; + sqliteStorage = SqliteStorageService(databasePathOverride: dbPath); + await sqliteStorage.init(); + await sqliteStorage.saveWorkoutSession(WorkoutSession( + id: 'sess1', date: DateTime(2026, 5, 1), duration: 40, + exercises: [ExerciseLog(exerciseId: 'bench_press', sets: [WorkoutSet(weight: 70, reps: 8)])], + )); + }); + + tearDown(() async { + await sqliteStorage.close(); + // Best-effort cleanup: the sqflite ffi connection may still hold the + // file handle open on some platforms (e.g. Windows), which would + // otherwise turn cleanup noise into a spurious test failure. + try { + final f = File(dbPath); + if (await f.exists()) await f.delete(); + } catch (_) {} + }); + + test('is not advertised when no SqlQueryService is provided', () { + final declared = + tools.buildTools().expand((t) => t.functionDeclarations ?? []).map((f) => f.name); + expect(declared, isNot(contains('run_sql_query'))); + }); + + test('is advertised and runs a live SELECT when wired', () async { + final withSql = CoachToolService( + workoutProvider: provider, + prManager: pr, + sqlQuery: SqlQueryService(dbPath), + ); + + final declared = + withSql.buildTools().expand((t) => t.functionDeclarations ?? []).map((f) => f.name); + expect(declared, contains('run_sql_query')); + + final result = await withSql.handleCall( + FunctionCall('run_sql_query', {'query': 'SELECT id, duration_min FROM sessions'}), + ); + expect(result['row_count'], 1); + expect((result['rows'] as List).first, {'id': 'sess1', 'duration_min': 40}); + }); + }); + test('exposes the expected tool declarations', () { final declared = tools .buildTools() diff --git a/workout-logger/test/health_data_sync_service_test.dart b/workout-logger/test/health_data_sync_service_test.dart new file mode 100644 index 0000000..39a1933 --- /dev/null +++ b/workout-logger/test/health_data_sync_service_test.dart @@ -0,0 +1,198 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/health_data_sync_service.dart'; +import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; + +class _RecordingHcService implements IHealthConnectService { + final List<({String method, DateTime from, DateTime to})> calls = []; + List heartRateSamples = const []; + List restingHrSamples = const []; + bool throwOnHeartRate = false; + Set grantedTypes = HealthReadType.values.toSet(); + + @override + Future> readSleepSessions(DateTime start, DateTime end) async { + calls.add((method: 'sleep', from: start, to: end)); + return const []; + } + + @override + Future> readHeartRateSamples(DateTime start, DateTime end) async { + calls.add((method: 'heart_rate', from: start, to: end)); + if (throwOnHeartRate) throw Exception('boom'); + return heartRateSamples; + } + + @override + Future> readRestingHeartRate(DateTime start, DateTime end) async { + calls.add((method: 'resting_heart_rate', from: start, to: end)); + return restingHrSamples; + } + + @override + Future> readHrvRmssd(DateTime start, DateTime end) async { + calls.add((method: 'hrv_rmssd', from: start, to: end)); + return const []; + } + + @override + Future> grantedReadTypes() async => grantedTypes; + @override + Future isAvailable() async => true; + @override + Future requestPermissions() async => true; + @override + Future hasPermissions() async => true; + @override + Future requestReadPermissions() async => true; + @override + Future syncWorkoutSession(WorkoutSession session, {String? title}) async => true; +} + +Future>> _rawQuery( + SqliteStorageService s, + String sql, [ + List? args, +]) async { + final db = await openReadOnlyDatabase(s.databasePath, singleInstance: false); + final rows = await db.rawQuery(sql, args); + await db.close(); + return rows; +} + +void main() { + late SqliteStorageService storage; + late _RecordingHcService hc; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + storage = SqliteStorageService(databasePathOverride: inMemoryDatabasePath); + await storage.init(); + hc = _RecordingHcService(); + }); + + tearDown(() async { + final path = storage.databasePath; + await storage.close(); + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + }); + + test('first sync backfills 90 days plus the 3-day lookback', () async { + final now = DateTime(2026, 8, 11, 9); + final service = HealthDataSyncService(healthConnectService: hc, storage: storage, now: () => now); + + await service.sync(); + + final sleepCall = hc.calls.firstWhere((c) => c.method == 'sleep'); + expect(sleepCall.to, now); + expect(sleepCall.from, now.subtract(const Duration(days: 93))); + }); + + test('second sync only re-fetches from watermark minus the 3-day lookback', () async { + final firstRun = DateTime(2026, 8, 1, 9); + final secondRun = DateTime(2026, 8, 11, 9); + var current = firstRun; + final service = HealthDataSyncService(healthConnectService: hc, storage: storage, now: () => current); + + await service.sync(force: true); + hc.calls.clear(); + current = secondRun; + await service.sync(force: true); + + final sleepCall = hc.calls.firstWhere((c) => c.method == 'sleep'); + expect(sleepCall.from, firstRun.subtract(const Duration(days: 3))); + expect(sleepCall.to, secondRun); + }); + + test('a sync within the 30-minute throttle window is skipped unless forced', () async { + final firstRun = DateTime(2026, 8, 11, 9, 0); + final soonAfter = DateTime(2026, 8, 11, 9, 10); + var current = firstRun; + final service = HealthDataSyncService(healthConnectService: hc, storage: storage, now: () => current); + + await service.sync(); + hc.calls.clear(); + current = soonAfter; + await service.sync(); + + expect(hc.calls, isEmpty); + }); + + test('force:true bypasses the throttle', () async { + final firstRun = DateTime(2026, 8, 11, 9, 0); + final soonAfter = DateTime(2026, 8, 11, 9, 10); + var current = firstRun; + final service = HealthDataSyncService(healthConnectService: hc, storage: storage, now: () => current); + + await service.sync(); + hc.calls.clear(); + current = soonAfter; + await service.sync(force: true); + + expect(hc.calls, isNotEmpty); + }); + + test('re-syncing the same sample does not duplicate rows', () async { + final now = DateTime(2026, 8, 11, 9); + hc.heartRateSamples = [HealthSample(time: DateTime(2026, 8, 10, 22), value: 62)]; + final service = HealthDataSyncService(healthConnectService: hc, storage: storage, now: () => now); + + await service.sync(force: true); + await service.sync(force: true); + + final rows = await _rawQuery( + storage, + "SELECT COUNT(*) AS c FROM health_samples WHERE type = 'heart_rate'", + ); + expect(rows.first['c'], 1); + }); + + test('a stream that throws does not block the others and leaves its watermark untouched', () async { + final now = DateTime(2026, 8, 11, 9); + hc.throwOnHeartRate = true; + hc.restingHrSamples = [HealthSample(time: now, value: 55)]; + final service = HealthDataSyncService(healthConnectService: hc, storage: storage, now: () => now); + + await service.sync(force: true); + + expect(await storage.getSetting('health_sync.heart_rate'), isNull); + expect(await storage.getSetting('health_sync.resting_heart_rate'), now.toIso8601String()); + + final rows = await _rawQuery( + storage, + "SELECT COUNT(*) AS c FROM health_samples WHERE type = 'resting_heart_rate'", + ); + expect(rows.first['c'], 1); + }); + + test('an ungranted stream is skipped entirely and its watermark never advances', () async { + final now = DateTime(2026, 8, 11, 9); + // Permission not yet granted for heart rate — simulates first launch + // before the user has opened Health Connect settings. + hc.grantedTypes = { + HealthReadType.sleep, + HealthReadType.restingHeartRate, + HealthReadType.hrv, + }; + final service = HealthDataSyncService(healthConnectService: hc, storage: storage, now: () => now); + + await service.sync(force: true); + + // The reader for the ungranted stream must never even be called. + expect(hc.calls.any((c) => c.method == 'heart_rate'), isFalse); + // And critically, its watermark must stay unset so a later grant still + // triggers the full 90-day backfill instead of resuming from `now`. + expect(await storage.getSetting('health_sync.heart_rate'), isNull); + }); +} diff --git a/workout-logger/test/screens/widgets/profile_sections_health_sync_test.dart b/workout-logger/test/screens/widgets/profile_sections_health_sync_test.dart new file mode 100644 index 0000000..3774852 --- /dev/null +++ b/workout-logger/test/screens/widgets/profile_sections_health_sync_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:repforge/screens/widgets/profile_sections.dart'; +import 'package:repforge/services/settings_provider.dart'; + +import '../../test_utils/mock_storage_service.dart'; + +void main() { + testWidgets('Sync now tile appears when readiness is enabled and invokes callback on tap', + (tester) async { + final settings = SettingsProvider(MockStorageService()); + await settings.init(); + await settings.setReadinessEnabled(true); + + var tapped = false; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HealthConnectSection( + settings: settings, + isLoading: false, + onToggle: (_) async {}, + isReadinessLoading: false, + onReadinessToggle: (_) async {}, + isHealthSyncLoading: false, + onHealthSyncNow: () => tapped = true, + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Sync coach data now'), findsOneWidget); + await tester.tap(find.text('Sync coach data now')); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('Sync now tile is hidden when readiness is disabled', (tester) async { + final settings = SettingsProvider(MockStorageService()); + await settings.init(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HealthConnectSection( + settings: settings, + isLoading: false, + onToggle: (_) async {}, + isReadinessLoading: false, + onReadinessToggle: (_) async {}, + isHealthSyncLoading: false, + onHealthSyncNow: () {}, + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Sync coach data now'), findsNothing); + }); +} diff --git a/workout-logger/test/sql_query_service_test.dart b/workout-logger/test/sql_query_service_test.dart new file mode 100644 index 0000000..e5a604b --- /dev/null +++ b/workout-logger/test/sql_query_service_test.dart @@ -0,0 +1,166 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/services/ai/sql_query_service.dart'; + +void main() { + late String dbPath; + late Database seedDb; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + dbPath = '${Directory.systemTemp.path}/sql_query_test_${DateTime.now().microsecondsSinceEpoch}.db'; + seedDb = await openDatabase(dbPath, version: 1, onCreate: (db, _) async { + await db.execute('CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)'); + await db.insert('widgets', {'id': 1, 'name': 'foo'}); + await db.insert('widgets', {'id': 2, 'name': 'bar'}); + await db.execute('CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)'); + await db.insert('settings', {'key': 'geminiApiKey', 'value': 'super-secret-key'}); + }); + }); + + tearDown(() async { + await seedDb.close(); + final f = File(dbPath); + if (await f.exists()) await f.delete(); + }); + + test('valid SELECT returns rows', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM widgets ORDER BY id'); + expect(result['row_count'], 2); + expect((result['rows'] as List).first, {'id': 1, 'name': 'foo'}); + }); + + test('rejects non-SELECT statements', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('DELETE FROM widgets'); + expect(result['error'], contains('Only SELECT')); + }); + + test('rejects multi-statement input', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM widgets; DROP TABLE widgets;'); + expect(result['error'], contains('single SQL statement')); + }); + + test('caps row count via limit', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM widgets', limit: 1); + expect(result['row_count'], 1); + }); + + test('returns error map instead of throwing on invalid SQL', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM does_not_exist'); + expect(result['error'], isNotNull); + }); + + test('rejects queries reading the settings table', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM settings'); + expect(result['error'], contains('restricted table')); + }); + + test('rejects queries reading sqlite_master', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM sqlite_master'); + expect(result['error'], contains('restricted table')); + }); + + test('rejects queries reading sqlite_temp_schema', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM sqlite_temp_schema'); + expect(result['error'], contains('restricted table')); + }); + + test('rejects queries reading sqlite_dbpage', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM sqlite_dbpage'); + expect(result['error'], contains('restricted table')); + }); + + test('rejects queries reading pragma_table_list', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM pragma_table_list'); + expect(result['error'], contains('restricted table')); + }); + + test('trailing line comment does not break the LIMIT wrapper', () async { + final service = SqlQueryService(dbPath); + final result = await service.runQuery('SELECT * FROM widgets -- get all'); + expect(result['error'], isNull); + expect(result['row_count'], 2); + }); + + test('does not close the app\'s shared connection to the same path', () async { + final service = SqlQueryService(dbPath); + + final first = await service.runQuery('SELECT * FROM widgets ORDER BY id'); + expect(first['error'], isNull); + + // Regression: opening a read-only connection at the same path as an + // already-open shared connection returns that shared instance unless + // singleInstance: false is passed. Closing it after the first query + // would then break every later access to the app's real connection — + // including seedDb here, standing in for the app's live database. + final rows = await seedDb.rawQuery('SELECT * FROM widgets ORDER BY id'); + expect(rows.length, 2); + + final second = await service.runQuery('SELECT * FROM widgets ORDER BY id'); + expect(second['error'], isNull); + expect(second['row_count'], 2); + }); + + test('can join workouts against sleep and HR data', () async { + await seedDb.execute('''CREATE TABLE health_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + timestamp TEXT NOT NULL, + value REAL NOT NULL + )'''); + await seedDb.execute('''CREATE TABLE sleep_sessions ( + id TEXT PRIMARY KEY, + start_ts TEXT NOT NULL, + end_ts TEXT NOT NULL, + light_min INTEGER, + deep_min INTEGER, + rem_min INTEGER, + awake_min INTEGER + )'''); + await seedDb.insert('health_samples', { + 'type': 'resting_heart_rate', + 'timestamp': '2026-08-10T07:00:00.000', + 'value': 58.0, + }); + await seedDb.insert('sleep_sessions', { + 'id': '2026-08-09T23:00:00.000', + 'start_ts': '2026-08-09T23:00:00.000', + 'end_ts': '2026-08-10T07:00:00.000', + 'light_min': 200, + 'deep_min': 70, + 'rem_min': 90, + 'awake_min': 5, + }); + + final service = SqlQueryService(dbPath); + final result = await service.runQuery(''' + SELECT w.name AS widget_name, s.deep_min AS deep_min, h.value AS resting_hr + FROM widgets w, sleep_sessions s + JOIN health_samples h ON h.type = 'resting_heart_rate' + WHERE w.id = 1 + '''); + + expect(result['error'], isNull); + expect(result['row_count'], 1); + expect((result['rows'] as List).first, { + 'widget_name': 'foo', + 'deep_min': 70, + 'resting_hr': 58.0, + }); + }); +} diff --git a/workout-logger/test/sqlite_storage_service_test.dart b/workout-logger/test/sqlite_storage_service_test.dart new file mode 100644 index 0000000..21d1a47 --- /dev/null +++ b/workout-logger/test/sqlite_storage_service_test.dart @@ -0,0 +1,601 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; +import 'package:repforge/data/exercise_database.dart'; + +void main() { + late SqliteStorageService storage; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + storage = SqliteStorageService(databasePathOverride: inMemoryDatabasePath); + await storage.init(); + }); + + tearDown(() async { + final path = storage.databasePath; + await storage.close(); + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + }); + + group('SqliteStorageService — init', () { + test('seeds default muscle groups', () async { + final groups = await storage.getAllMuscleGroups(); + expect(groups, isNotEmpty); + expect(groups.any((g) => g.name == 'Chest'), isTrue); + }); + }); + + Future>> rawQuery( + SqliteStorageService s, + String sql, [ + List? args, + ]) async { + final db = await openReadOnlyDatabase(s.databasePath, singleInstance: false); + final rows = await db.rawQuery(sql, args); + await db.close(); + return rows; + } + + group('SqliteStorageService — health data', () { + test('upsertHealthSamples replaces duplicates on (type, timestamp)', () async { + final t = DateTime(2026, 8, 10, 22, 30); + await storage.upsertHealthSamples('heart_rate', [HealthSample(time: t, value: 60)]); + await storage.upsertHealthSamples('heart_rate', [HealthSample(time: t, value: 65)]); + + final rows = await rawQuery( + storage, + "SELECT value FROM health_samples WHERE type = 'heart_rate'", + ); + expect(rows.length, 1); + expect(rows.first['value'], 65.0); + }); + + test('upsertHealthSamples stores timestamps converted to local, not UTC', () async { + final utcTime = DateTime.utc(2026, 8, 10, 21, 0); + await storage.upsertHealthSamples('heart_rate', [HealthSample(time: utcTime, value: 60)]); + + final rows = await rawQuery( + storage, + "SELECT timestamp FROM health_samples WHERE type = 'heart_rate'", + ); + final stored = rows.first['timestamp'] as String; + expect(stored.contains('Z'), isFalse); + expect(stored, utcTime.toLocal().toIso8601String()); + }); + + test('upsertSleepSessions replaces stage intervals for a re-synced session', () async { + final start = DateTime(2026, 8, 10, 23); + final end = DateTime(2026, 8, 11, 7); + + await storage.upsertSleepSessions([ + SleepPeriod( + start: start, + end: end, + lightMinutes: 200, + deepMinutes: 60, + remMinutes: 100, + awakeMinutes: 10, + stageTimeline: [ + SleepStageInterval(start: start, end: start.add(const Duration(hours: 1)), stage: 'light'), + ], + ), + ]); + + await storage.upsertSleepSessions([ + SleepPeriod( + start: start, + end: end, + lightMinutes: 190, + deepMinutes: 70, + remMinutes: 100, + awakeMinutes: 10, + stageTimeline: [ + SleepStageInterval(start: start, end: start.add(const Duration(hours: 2)), stage: 'deep'), + ], + ), + ]); + + final sessions = await rawQuery(storage, 'SELECT id, deep_min FROM sleep_sessions'); + expect(sessions.length, 1); + expect(sessions.first['deep_min'], 70); + + final intervals = await rawQuery( + storage, + 'SELECT stage FROM sleep_stage_intervals WHERE sleep_session_id = ?', + [sessions.first['id']], + ); + expect(intervals.length, 1); + expect(intervals.first['stage'], 'deep'); + }); + }); + + group('SqliteStorageService — schema upgrade', () { + test('onUpgrade adds health tables to a pre-existing v1 database', () async { + final path = + '${Directory.systemTemp.path}/sqlite_v1_upgrade_${DateTime.now().microsecondsSinceEpoch}.db'; + final v1 = await openDatabase( + path, + version: 1, + onCreate: (db, v) async { + await db.execute('''CREATE TABLE muscle_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + growth_rate REAL NOT NULL DEFAULT 0, + last_updated TEXT NOT NULL + )'''); + }, + ); + await v1.close(); + + final upgraded = SqliteStorageService(databasePathOverride: path); + await upgraded.init(); + + final tableRows = await rawQuery( + upgraded, + "SELECT name FROM sqlite_master WHERE type = 'table'", + ); + final names = tableRows.map((r) => r['name'] as String).toSet(); + expect(names, containsAll(['health_samples', 'sleep_sessions', 'sleep_stage_intervals'])); + + await upgraded.close(); + await File(path).delete(); + }); + }); + + group('SqliteStorageService — workout sessions', () { + test('saveWorkoutSession + getWorkoutSession round-trips nested sets', () async { + final session = WorkoutSession( + id: 's1', + date: DateTime(2026, 7, 10), + duration: 45, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [ + WorkoutSet(weight: 60, reps: 8), + WorkoutSet(weight: 65, reps: 6, isDropset: true, drops: [ + DropsetEntry(weight: 50, reps: 10), + ]), + ], + ), + ], + ); + + await storage.saveWorkoutSession(session); + final fetched = await storage.getWorkoutSession('s1'); + + expect(fetched, isNotNull); + expect(fetched!.duration, 45); + expect(fetched.exercises.single.sets.length, 2); + expect(fetched.exercises.single.sets.first.weight, 60); + expect(fetched.exercises.single.sets[1].isDropset, isTrue); + expect(fetched.exercises.single.sets[1].drops!.single.weight, 50); + }); + + test('preserves set order with 11+ sets (regression: synthetic ids like ' + '"_10" sort before "_2" lexicographically, so ordering must use ' + 'rowid, not id)', () async { + final weights = [for (var i = 0; i < 11; i++) 40.0 + i]; + final session = WorkoutSession( + id: 's_order', + date: DateTime(2026, 7, 15), + duration: 60, + exercises: [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [for (final w in weights) WorkoutSet(weight: w, reps: 5)], + ), + ], + ); + + await storage.saveWorkoutSession(session); + final fetched = await storage.getWorkoutSession('s_order'); + + expect( + fetched!.exercises.single.sets.map((s) => s.weight).toList(), + weights, + ); + }); + + test('preserves exercise-log order with 11+ exercises in one session', + () async { + final session = WorkoutSession( + id: 's_order_logs', + date: DateTime(2026, 7, 16), + duration: 90, + exercises: [ + for (var i = 0; i < 11; i++) + ExerciseLog( + exerciseId: 'exercise_$i', + sets: [WorkoutSet(weight: 10.0 + i, reps: 5)], + ), + ], + ); + + await storage.saveWorkoutSession(session); + final fetched = await storage.getWorkoutSession('s_order_logs'); + + expect( + fetched!.exercises.map((e) => e.exerciseId).toList(), + [for (var i = 0; i < 11; i++) 'exercise_$i'], + ); + }); + + test('saveWorkoutSession overwrites previous sets on re-save', () async { + final session = WorkoutSession( + id: 's2', + date: DateTime(2026, 7, 1), + duration: 30, + exercises: [ + ExerciseLog(exerciseId: 'squat', sets: [WorkoutSet(weight: 100, reps: 5)]), + ], + ); + await storage.saveWorkoutSession(session); + + final updated = session.copyWith( + exercises: [ + ExerciseLog(exerciseId: 'squat', sets: [WorkoutSet(weight: 110, reps: 3)]), + ], + ); + await storage.saveWorkoutSession(updated); + + final fetched = await storage.getWorkoutSession('s2'); + expect(fetched!.exercises.single.sets.length, 1); + expect(fetched.exercises.single.sets.first.weight, 110); + }); + + test('deleteWorkoutSession removes the session', () async { + final session = WorkoutSession( + id: 's3', + date: DateTime.now(), + duration: 20, + exercises: [ExerciseLog(exerciseId: 'row', sets: [WorkoutSet(weight: 40, reps: 10)])], + ); + await storage.saveWorkoutSession(session); + await storage.deleteWorkoutSession('s3'); + expect(await storage.getWorkoutSession('s3'), isNull); + }); + + test('getAllWorkoutSessions returns most-recent first', () async { + await storage.saveWorkoutSession( + WorkoutSession(id: 'old', date: DateTime(2026, 1, 1), duration: 10, exercises: []), + ); + await storage.saveWorkoutSession( + WorkoutSession(id: 'new', date: DateTime(2026, 6, 1), duration: 10, exercises: []), + ); + final all = await storage.getAllWorkoutSessions(); + expect(all.first.id, 'new'); + }); + + test('getSessionsInDateRange filters by date', () async { + await storage.saveWorkoutSession( + WorkoutSession(id: 'a', date: DateTime(2026, 1, 1), duration: 10, exercises: []), + ); + await storage.saveWorkoutSession( + WorkoutSession(id: 'b', date: DateTime(2026, 6, 1), duration: 10, exercises: []), + ); + final result = await storage.getSessionsInDateRange(DateTime(2026, 5, 1), DateTime(2026, 7, 1)); + expect(result.map((s) => s.id), ['b']); + }); + + test('getSessionsForExercise filters by exercise id', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'c1', date: DateTime.now(), duration: 10, + exercises: [ExerciseLog(exerciseId: 'deadlift', sets: [WorkoutSet(weight: 120, reps: 5)])], + )); + await storage.saveWorkoutSession(WorkoutSession( + id: 'c2', date: DateTime.now(), duration: 10, + exercises: [ExerciseLog(exerciseId: 'squat', sets: [WorkoutSet(weight: 100, reps: 5)])], + )); + final result = await storage.getSessionsForExercise('deadlift'); + expect(result.map((s) => s.id), ['c1']); + }); + + test('saveWorkoutSession + getWorkoutSession round-trips bodyWeightAtLog', () async { + final session = WorkoutSession( + id: 's4', + date: DateTime(2026, 7, 5), + duration: 25, + exercises: [ + ExerciseLog( + exerciseId: 'assisted_pullup', + sets: [WorkoutSet(weight: 20, reps: 8, bodyWeightAtLog: 75.5)], + ), + ], + ); + await storage.saveWorkoutSession(session); + final fetched = await storage.getWorkoutSession('s4'); + expect(fetched!.exercises.single.sets.single.bodyWeightAtLog, 75.5); + }); + }); + + group('SqliteStorageService — routines', () { + test('saveRoutine + getRoutine round-trips ordered exercise ids', () async { + await storage.saveRoutine(Routine( + id: 'r1', + name: 'Push Day', + exerciseIds: ['bench_press', 'shoulder_press', 'triceps_pushdown'], + )); + final fetched = await storage.getRoutine('r1'); + expect(fetched!.name, 'Push Day'); + expect(fetched.exerciseIds, ['bench_press', 'shoulder_press', 'triceps_pushdown']); + }); + + test('saveRoutine overwrites exercise order on re-save', () async { + await storage.saveRoutine(Routine(id: 'r2', name: 'Pull Day', exerciseIds: ['a', 'b'])); + await storage.saveRoutine(Routine(id: 'r2', name: 'Pull Day', exerciseIds: ['b', 'a', 'c'])); + final fetched = await storage.getRoutine('r2'); + expect(fetched!.exerciseIds, ['b', 'a', 'c']); + }); + + test('deleteRoutine removes it', () async { + await storage.saveRoutine(Routine(id: 'r3', name: 'Legs', exerciseIds: ['squat'])); + await storage.deleteRoutine('r3'); + expect(await storage.getRoutine('r3'), isNull); + }); + + test('getAllRoutines returns all saved routines', () async { + await storage.saveRoutine(Routine(id: 'r4', name: 'A', exerciseIds: [])); + await storage.saveRoutine(Routine(id: 'r5', name: 'B', exerciseIds: [])); + final all = await storage.getAllRoutines(); + expect(all.map((r) => r.id), containsAll(['r4', 'r5'])); + }); + }); + + group('SqliteStorageService — targets', () { + test('saveTarget + getTarget round-trips', () async { + await storage.saveTarget(Target( + id: 't1', + exerciseId: 'bench_press', + targetType: 'weight', + targetValue: 100, + currentValue: 70, + )); + final fetched = await storage.getTarget('t1'); + expect(fetched!.targetValue, 100); + expect(fetched.currentValue, 70); + }); + + test('deleteTarget removes it', () async { + await storage.saveTarget(Target(id: 't2', exerciseId: 'squat', targetType: 'weight', targetValue: 150)); + await storage.deleteTarget('t2'); + expect(await storage.getTarget('t2'), isNull); + }); + + test('getTargetsForExercise filters by exercise id', () async { + await storage.saveTarget(Target(id: 't3', exerciseId: 'squat', targetType: 'weight', targetValue: 150)); + await storage.saveTarget(Target(id: 't4', exerciseId: 'deadlift', targetType: 'weight', targetValue: 180)); + final result = await storage.getTargetsForExercise('squat'); + expect(result.map((t) => t.id), ['t3']); + }); + }); + + group('SqliteStorageService — muscle groups', () { + test('updateMuscleGroupGrowthRate updates an existing group', () async { + final groups = await storage.getAllMuscleGroups(); + final chest = groups.firstWhere((g) => g.name == 'Chest'); + await storage.updateMuscleGroupGrowthRate(chest.id, 2.5); + final updated = await storage.getMuscleGroup(chest.id); + expect(updated!.growthRate, 2.5); + }); + }); + + group('SqliteStorageService — custom exercises', () { + test('saveCustomExercise + getExercise round-trips muscle activations', () async { + final exercise = Exercise( + id: 'custom1', + name: 'Cable Crossover', + category: 'isolation', + isCustom: true, + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 80), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 20), + ], + ); + await storage.saveCustomExercise(exercise); + + final fetched = await storage.getExercise('custom1'); + expect(fetched, isNotNull); + expect(fetched!.name, 'Cable Crossover'); + expect(fetched.muscleActivations.length, 2); + expect(fetched.primaryMuscle, 'chest'); + }); + + test('getExercise falls back to built-in exercises', () async { + final builtIns = ExerciseDatabase.getAll(); + final known = builtIns.first; + final fetched = await storage.getExercise(known.id); + expect(fetched!.name, known.name); + }); + + test('getAllExercises merges built-in and custom', () async { + await storage.saveCustomExercise(Exercise( + id: 'custom2', + name: 'My Exercise', + category: 'compound', + isCustom: true, + muscleActivations: [MuscleActivation(muscleGroupId: 'back', activationPercentage: 100)], + )); + final all = await storage.getAllExercises(); + expect(all.any((e) => e.id == 'custom2'), isTrue); + expect(all.length, greaterThan(1)); + }); + + test('deleteCustomExercise removes it and its activations', () async { + await storage.saveCustomExercise(Exercise( + id: 'custom3', + name: 'Temp', + category: 'isolation', + isCustom: true, + muscleActivations: [MuscleActivation(muscleGroupId: 'biceps', activationPercentage: 100)], + )); + await storage.deleteCustomExercise('custom3'); + expect(await storage.getExercise('custom3'), isNull); + final custom = await storage.getCustomExercises(); + expect(custom.any((e) => e.id == 'custom3'), isFalse); + }); + }); + + group('SqliteStorageService — settings', () { + test('saveSetting + getSetting round-trips, overwrite replaces value', () async { + await storage.saveSetting('user_name', 'Alex'); + expect(await storage.getSetting('user_name'), 'Alex'); + await storage.saveSetting('user_name', 'Sam'); + expect(await storage.getSetting('user_name'), 'Sam'); + }); + + test('getSetting returns null for unknown key', () async { + expect(await storage.getSetting('does_not_exist'), isNull); + }); + }); + + group('SqliteStorageService — personal records', () { + test('savePersonalRecord + getPersonalRecord round-trips', () async { + await storage.savePersonalRecord(PersonalRecord( + exerciseId: 'bench_press', + bestWeight: 90, + bestReps: 5, + bestVolume: 450, + achievedAt: DateTime(2026, 4, 1), + )); + final pr = await storage.getPersonalRecord('bench_press'); + expect(pr!.bestWeight, 90); + }); + + test('getAllPersonalRecords returns everything saved', () async { + await storage.savePersonalRecord(PersonalRecord( + exerciseId: 'squat', bestWeight: 150, bestReps: 3, bestVolume: 450, achievedAt: DateTime(2026, 3, 1), + )); + final all = await storage.getAllPersonalRecords(); + expect(all.any((r) => r.exerciseId == 'squat'), isTrue); + }); + }); + + group('SqliteStorageService — training programs', () { + test('saveTrainingProgram + getTrainingProgram round-trips phases/weeks', () async { + final program = TrainingProgram( + id: 'p1', + name: '12-Week Strength', + totalWeeks: 12, + phases: [], + weeks: [], + ); + await storage.saveTrainingProgram(program); + final fetched = await storage.getTrainingProgram('p1'); + expect(fetched!.name, '12-Week Strength'); + expect(fetched.totalWeeks, 12); + }); + + test('deleteTrainingProgram removes it', () async { + await storage.saveTrainingProgram(TrainingProgram(id: 'p2', name: 'X', totalWeeks: 4, phases: [], weeks: [])); + await storage.deleteTrainingProgram('p2'); + expect(await storage.getTrainingProgram('p2'), isNull); + }); + }); + + group('SqliteStorageService — conversations', () { + test('saveConversation + getConversation round-trips messages', () async { + final conversation = Conversation( + id: 'c1', + title: 'Progress check', + messages: [ChatMessage(role: 'user', text: 'How is my bench doing?')], + ); + await storage.saveConversation(conversation); + final fetched = await storage.getConversation('c1'); + expect(fetched!.messages.single.text, 'How is my bench doing?'); + }); + + test('getAllConversations returns most-recently-updated first', () async { + await storage.saveConversation(Conversation( + id: 'c2', title: 'Old', updatedAt: DateTime(2026, 1, 1), messages: [], + )); + await storage.saveConversation(Conversation( + id: 'c3', title: 'New', updatedAt: DateTime(2026, 6, 1), messages: [], + )); + final all = await storage.getAllConversations(); + expect(all.first.id, 'c3'); + }); + + test('deleteConversation removes it', () async { + await storage.saveConversation(Conversation(id: 'c4', title: 'Temp', messages: [])); + await storage.deleteConversation('c4'); + expect(await storage.getConversation('c4'), isNull); + }); + }); + + group('SqliteStorageService — quick stats', () { + test('getQuickStats aggregates the last 7 days', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'stat1', + date: DateTime.now(), + duration: 30, + exercises: [ExerciseLog(exerciseId: 'bench_press', sets: [WorkoutSet(weight: 60, reps: 10)])], + )); + final stats = await storage.getQuickStats(); + expect(stats['totalWorkouts'], greaterThanOrEqualTo(1)); + expect(stats['weeklyWorkouts'], greaterThanOrEqualTo(1)); + }); + }); + + group('SqliteStorageService — export/import', () { + test('exportAllData includes sessions, routines, settings', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'exp1', date: DateTime(2026, 5, 1), duration: 20, + exercises: [ExerciseLog(exerciseId: 'row', sets: [WorkoutSet(weight: 40, reps: 10)])], + )); + await storage.saveRoutine(Routine(id: 'exp_r1', name: 'Export Routine', exerciseIds: ['row'])); + await storage.saveSetting('unit', 'kg'); + + final json = await storage.exportAllData(); + final data = jsonDecode(json) as Map; + + expect((data['sessions'] as List).any((s) => s['id'] == 'exp1'), isTrue); + expect((data['routines'] as List).any((r) => r['id'] == 'exp_r1'), isTrue); + expect((data['settings'] as Map)['unit'], 'kg'); + }); + + test('importData merges without overwriting existing ids', () async { + await storage.saveWorkoutSession(WorkoutSession( + id: 'imp1', date: DateTime(2026, 1, 1), duration: 15, + exercises: [ExerciseLog(exerciseId: 'row', sets: [WorkoutSet(weight: 30, reps: 12)])], + )); + + final payload = jsonEncode({ + 'sessions': [ + { + 'id': 'imp1', // already exists — must be skipped + 'date': DateTime(2099, 1, 1).toIso8601String(), + 'duration': 999, + 'exercises': [], + }, + { + 'id': 'imp2', // new — must be imported + 'date': DateTime(2026, 2, 1).toIso8601String(), + 'duration': 25, + 'exercises': [], + }, + ], + 'settings': {'imported_key': 'imported_value'}, + }); + + await storage.importData(payload); + + final existing = await storage.getWorkoutSession('imp1'); + expect(existing!.duration, 15); // untouched + final imported = await storage.getWorkoutSession('imp2'); + expect(imported!.duration, 25); + expect(await storage.getSetting('imported_key'), 'imported_value'); + }); + }); +} diff --git a/workout-logger/test/storage_backend_resolver_test.dart b/workout-logger/test/storage_backend_resolver_test.dart new file mode 100644 index 0000000..e6995ca --- /dev/null +++ b/workout-logger/test/storage_backend_resolver_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/storage_service.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; +import 'package:repforge/services/storage_backend_resolver.dart'; + +/// Forces the migration to fail without adding new production API surface — +/// [SqliteStorageService.saveWorkoutSession] is the first write +/// [StorageMigrationService.migrate] performs against a seeded hiveStorage. +class _FailingSqliteStorage extends SqliteStorageService { + _FailingSqliteStorage() : super(databasePathOverride: inMemoryDatabasePath); + + @override + Future saveWorkoutSession(WorkoutSession session) async => + throw StateError('forced migration failure'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StorageService hiveStorage; + late SqliteStorageService sqliteStorage; + + setUpAll(() async { + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async => + call.method == 'getApplicationDocumentsDirectory' ? './test/tmp_hive_backend_resolver' : null, + ); + Hive.init('./test/tmp_hive_backend_resolver'); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + hiveStorage = StorageService(); + await hiveStorage.init(); + sqliteStorage = SqliteStorageService(databasePathOverride: inMemoryDatabasePath); + await sqliteStorage.init(); + addTearDown(sqliteStorage.close); + }); + + // hiveStorage reuses the same on-disk box across every test in this file + // (only Hive.init() runs once, in setUpAll) — reset the flag it wrote so + // each test starts from a clean, order-independent state instead of + // relying on test declaration order. + tearDown(() async { + await Hive.box('settings').delete(storageMigratedFlagKey); + }); + + tearDownAll(() async { + await Hive.close(); + await Hive.deleteFromDisk(); + }); + + test('alreadyMigrated false, migration fails, falls back to hive and writes no flag', + () async { + // migrate() only reaches saveWorkoutSession if there's a session to + // migrate — seed one so the forced failure actually triggers. + await hiveStorage.saveWorkoutSession(WorkoutSession( + id: 'fail1', date: DateTime(2026, 5, 1), duration: 10, exercises: const [], + )); + final failing = _FailingSqliteStorage(); + await failing.init(); + addTearDown(failing.close); + + final result = await resolveStorageBackend( + hiveStorage: hiveStorage, + sqliteStorage: failing, + alreadyMigrated: false, + ); + + expect(result, same(hiveStorage)); + expect(await hiveStorage.getSetting(storageMigratedFlagKey), isNull); + }); + + test('alreadyMigrated true returns sqlite without touching migration', () async { + final result = await resolveStorageBackend( + hiveStorage: hiveStorage, + sqliteStorage: sqliteStorage, + alreadyMigrated: true, + ); + expect(result, same(sqliteStorage)); + }); + + test('alreadyMigrated false, migration succeeds, returns sqlite and writes flag', () async { + final result = await resolveStorageBackend( + hiveStorage: hiveStorage, + sqliteStorage: sqliteStorage, + alreadyMigrated: false, + ); + expect(result, same(sqliteStorage)); + expect(await hiveStorage.getSetting(storageMigratedFlagKey), 'true'); + }); +} diff --git a/workout-logger/test/storage_migration_service_test.dart b/workout-logger/test/storage_migration_service_test.dart new file mode 100644 index 0000000..6847049 --- /dev/null +++ b/workout-logger/test/storage_migration_service_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/storage_service.dart'; +import 'package:repforge/services/sqlite_storage_service.dart'; +import 'package:repforge/services/storage_migration_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StorageService hiveStorage; + late SqliteStorageService sqliteStorage; + + setUpAll(() async { + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async => + call.method == 'getApplicationDocumentsDirectory' ? './test/tmp_hive_migration_service' : null, + ); + Hive.init('./test/tmp_hive_migration_service'); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + hiveStorage = StorageService(); + await hiveStorage.init(); + sqliteStorage = SqliteStorageService(databasePathOverride: inMemoryDatabasePath); + await sqliteStorage.init(); + }); + + tearDownAll(() async { + await Hive.close(); + await Hive.deleteFromDisk(); + }); + + test('migrate copies every entity type from Hive to SQLite', () async { + await hiveStorage.saveWorkoutSession(WorkoutSession( + id: 'sess1', date: DateTime(2026, 5, 1), duration: 40, + exercises: [ExerciseLog(exerciseId: 'bench_press', sets: [WorkoutSet(weight: 70, reps: 8)])], + )); + await hiveStorage.saveRoutine(Routine(id: 'r1', name: 'Push Day', exerciseIds: ['bench_press'])); + await hiveStorage.saveTarget(Target(id: 't1', exerciseId: 'bench_press', targetType: 'weight', targetValue: 100)); + await hiveStorage.savePersonalRecord(PersonalRecord( + exerciseId: 'bench_press', bestWeight: 90, bestReps: 5, bestVolume: 450, achievedAt: DateTime(2026, 4, 1), + )); + await hiveStorage.saveCustomExercise(Exercise( + id: 'custom_mig', name: 'Migrated Exercise', category: 'isolation', isCustom: true, + muscleActivations: [MuscleActivation(muscleGroupId: 'chest', activationPercentage: 100)], + )); + await hiveStorage.saveConversation(Conversation(id: 'conv1', title: 'Chat', messages: [])); + await hiveStorage.saveSetting('user_name', 'Alex'); + await hiveStorage.saveTrainingProgram(TrainingProgram( + id: 'prog1', name: 'Push Pull Legs', totalWeeks: 6, phases: const [], weeks: const [], + )); + // Growth rate starts at the seeded default (0); modify it on the Hive + // side so the migrated value can be distinguished from an unmigrated one. + await hiveStorage.updateMuscleGroupGrowthRate('chest', 0.42); + + await StorageMigrationService(hiveStorage, sqliteStorage).migrate(); + + expect((await sqliteStorage.getWorkoutSession('sess1'))?.duration, 40); + expect((await sqliteStorage.getRoutine('r1'))?.name, 'Push Day'); + expect((await sqliteStorage.getTarget('t1'))?.targetValue, 100); + expect((await sqliteStorage.getPersonalRecord('bench_press'))?.bestWeight, 90); + expect((await sqliteStorage.getExercise('custom_mig'))?.name, 'Migrated Exercise'); + expect((await sqliteStorage.getConversation('conv1'))?.title, 'Chat'); + expect(await sqliteStorage.getSetting('user_name'), 'Alex'); + expect((await sqliteStorage.getTrainingProgram('prog1'))?.name, 'Push Pull Legs'); + expect((await sqliteStorage.getMuscleGroup('chest'))?.growthRate, 0.42); + }); +} diff --git a/workout-logger/test/storage_service_test.dart b/workout-logger/test/storage_service_test.dart index 4517648..3e38129 100644 --- a/workout-logger/test/storage_service_test.dart +++ b/workout-logger/test/storage_service_test.dart @@ -177,5 +177,15 @@ void main() { final val = await storage.getSetting('test_setting_key'); expect(val, equals('test_val')); }); + + test('getAllSettingsForMigration returns every saved key/value', () async { + await storage.saveSetting('mig_key_1', 'value_1'); + await storage.saveSetting('mig_key_2', 'value_2'); + + final all = await storage.getAllSettingsForMigration(); + + expect(all['mig_key_1'], 'value_1'); + expect(all['mig_key_2'], 'value_2'); + }); }); } diff --git a/workout-logger/test/test_utils/mock_ml_service.dart b/workout-logger/test/test_utils/mock_ml_service.dart index ce9b4c0..9372e15 100644 --- a/workout-logger/test/test_utils/mock_ml_service.dart +++ b/workout-logger/test/test_utils/mock_ml_service.dart @@ -93,6 +93,7 @@ class MockMLService implements IMLService { int maxReps = 12, Map? recoveryScores, List? primaryMuscleIds, + DateTime? asOf, }) { recommendSetsCallCount++; lastRecommendedLastSession = lastSession; diff --git a/workout-logger/test/test_utils/test_harness.dart b/workout-logger/test/test_utils/test_harness.dart index 5adc365..17cd481 100644 --- a/workout-logger/test/test_utils/test_harness.dart +++ b/workout-logger/test/test_utils/test_harness.dart @@ -16,6 +16,7 @@ import 'package:repforge/services/managers/pr_manager.dart'; import 'package:repforge/services/managers/readiness_manager.dart'; import 'package:repforge/services/interfaces/health_connect_service_interface.dart'; import 'package:repforge/services/interfaces/ml_service_interface.dart'; +import 'package:repforge/services/health_data_sync_service.dart'; import 'mock_storage_service.dart'; import 'mock_ml_service.dart'; import 'stub_health_connect_service.dart'; @@ -60,6 +61,7 @@ class TestHarness { ChangeNotifierProvider.value(value: rm), Provider.value(value: hhm), Provider.value(value: const StubHcService()), + Provider.value(value: null), Provider.value(value: ApiService()), Provider.value(value: tools), Provider.value(value: MockMLService()),