diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index fae882d..05c47a1 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -46,7 +46,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 12; + int get schemaVersion => 13; @override MigrationStrategy get migration { @@ -97,6 +97,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 12) { await _migrateToSchema12(migrator); } + if (from < 13) { + await _migrateToSchema13(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -359,4 +362,111 @@ extension on AppDatabase { await migrator.createTable(shareInboxItems); await migrator.createTable(pendingShareActions); } + + Future _migrateToSchema13() async { + await customStatement('PRAGMA foreign_keys = OFF'); + await customStatement(''' +CREATE TABLE exercises_new ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + schema_version INTEGER NOT NULL DEFAULT 1, + sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')), + local_revision INTEGER NOT NULL CHECK (local_revision >= 0), + origin_device_id TEXT NOT NULL, + future_owner_profile_id TEXT, + last_synced_at INTEGER, + remote_revision TEXT, + name TEXT NOT NULL, + description TEXT, + icon_media_id TEXT REFERENCES media_assets(id), + video_media_id TEXT REFERENCES media_assets(id), + has_time_measure INTEGER NOT NULL CHECK (has_time_measure IN (0, 1)), + has_reps_measure INTEGER NOT NULL CHECK (has_reps_measure IN (0, 1)), + has_score_measure INTEGER NOT NULL CHECK (has_score_measure IN (0, 1)), + score_input_mode TEXT NOT NULL DEFAULT 'manual', + score_label TEXT, + score_unit TEXT, + default_target_time_seconds INTEGER, + default_target_reps INTEGER, + default_target_score REAL, + default_target_score_time_ms INTEGER, + archived_at INTEGER, + CHECK (length(trim(origin_device_id)) > 0), + CHECK (length(trim(name)) > 0), + CHECK (has_time_measure OR has_reps_measure OR has_score_measure), + CHECK (score_input_mode IN ('manual', 'stopwatch')), + CHECK (has_score_measure OR score_input_mode = 'manual'), + CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR (score_label IS NOT NULL AND length(trim(score_label)) > 0 AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0)), + CHECK (default_target_time_seconds IS NULL OR default_target_time_seconds > 0), + CHECK (default_target_reps IS NULL OR default_target_reps > 0), + CHECK (default_target_score IS NULL OR default_target_score >= 0), + CHECK (default_target_score_time_ms IS NULL OR default_target_score_time_ms > 0), + CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0), + CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0) +) +'''); + await customStatement(''' +INSERT INTO exercises_new ( + id, + created_at, + updated_at, + deleted_at, + schema_version, + sync_state, + local_revision, + origin_device_id, + future_owner_profile_id, + last_synced_at, + remote_revision, + name, + description, + icon_media_id, + video_media_id, + has_time_measure, + has_reps_measure, + has_score_measure, + score_input_mode, + score_label, + score_unit, + default_target_time_seconds, + default_target_reps, + default_target_score, + default_target_score_time_ms, + archived_at +) +SELECT + id, + created_at, + updated_at, + deleted_at, + schema_version, + sync_state, + local_revision, + origin_device_id, + future_owner_profile_id, + last_synced_at, + remote_revision, + name, + description, + icon_media_id, + video_media_id, + has_time_measure, + has_reps_measure, + has_score_measure, + score_input_mode, + score_label, + score_unit, + default_target_time_seconds, + default_target_reps, + default_target_score, + default_target_score_time_ms, + archived_at +FROM exercises +'''); + await customStatement('DROP TABLE exercises'); + await customStatement('ALTER TABLE exercises_new RENAME TO exercises'); + await customStatement('PRAGMA foreign_keys = ON'); + } } diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 36d534d..51c2583 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -204,7 +204,7 @@ class Exercises extends SyncableTable { 'CHECK (default_target_time_seconds IS NULL OR ' 'default_target_time_seconds > 0)', 'CHECK (default_target_reps IS NULL OR default_target_reps > 0)', - 'CHECK (default_target_score IS NULL OR default_target_score > 0)', + 'CHECK (default_target_score IS NULL OR default_target_score >= 0)', 'CHECK (default_target_score_time_ms IS NULL OR ' 'default_target_score_time_ms > 0)', ]; diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index c1b3f2b..ba2925b 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -79,6 +79,25 @@ void main() { expect(changes.every((change) => change.entityType == 'Exercise'), isTrue); }); + test('exercise repository accepts zero manual default score', () async { + final exercise = Exercise( + metadata: _metadata('exercise-score-zero', DateTime.utc(2026, 7, 17, 12)), + name: 'Score nul', + hasTimeMeasure: false, + hasRepsMeasure: false, + hasScoreMeasure: true, + scoreLabel: 'Score', + scoreUnit: 'pts', + defaultTargetScore: 0, + ); + + await exerciseRepository.save(exercise); + + final restored = await exerciseRepository.findById(exercise.metadata.id); + expect(restored, isNotNull); + expect(restored!.defaultTargetScore, 0); + }); + test('exercise repository round-trips configured steps', () async { final now = DateTime.utc(2026, 7, 17, 12); final exercise = Exercise(