diff --git a/docs/superpowers/plans/2026-07-05-dive-planner-phase2-domain.md b/docs/superpowers/plans/2026-07-05-dive-planner-phase2-domain.md new file mode 100644 index 000000000..beef7aeda --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dive-planner-phase2-domain.md @@ -0,0 +1,587 @@ +# Dive Planner Phase 2: Plan Domain, Persistence, Sync, PlanEngine — 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 dive plans (three synced Drift tables at schema v100), give them a repository with full sync participation, build the `PlanEngine` orchestrator on the Phase 1 engine seams (`DecoModel`/`BreathingConfig`/`SchedulePolicy`/`DiveEnvironment`), and make the existing planner's save/load stubs real. + +**Architecture:** New feature module `lib/features/planner/` (domain entities + PlanEngine + repository). The OLD `dive_planner` feature keeps `PlanCalculatorService` untouched and keeps rendering — Phase 3 cuts the UI over. Persistence follows the checklists-v98 recipe exactly (the most recent three-table synced feature). Branch: stacked on `worktree-dive-planner-phase1-engine` (PR #484) — the engine API this builds on. + +**Tech Stack:** Drift 2.30 (codegen via build_runner), the app's changeset-log sync (HLC conflict resolution), Riverpod, pure-Dart engine. + +**Spec:** `docs/superpowers/specs/2026-07-05-dive-planner-redesign-design.md` (section "Plan domain and persistence (Phase 2)") + +## Global Constraints + +- Schema version goes **99 → 100** (`lib/core/database/database.dart:1847`). Memory note "v98+" is stale; v98=checklists, v99=buddy roles already exist. +- All 18 sync registration sites (14 serializer + 3 sync_service + 1 sync_repository) MUST be filled; the structural tests (`sync_base_streaming_parity_test.dart`, `sync_parent_refs_completeness_test.dart`, `sync_data_serializer_record_ids_test.dart`) are the safety net — run them after registration. +- Entity type strings (used in sync + deletion log, never change them): `divePlans`, `divePlanTanks`, `divePlanSegments`. SQLite tables: `dive_plans`, `dive_plan_tanks`, `dive_plan_segments`. +- `.toCompanion(false)` for upserts of HLC entities (all three tables have `hlc`). +- After every repository write: `markRecordPending` + `SyncEventBus.notifyLocalChange()`; after every delete: `logDeletion` per row (children too — parent-surviving child deletes need per-row tombstones). +- Existing tests must keep passing. `dart format .` before each commit; `flutter analyze` clean. Run specific test files, not broad dirs. Commit per task (pre-authorized), no Co-Authored-By. +- Python3 for any computed test vectors — never from recall. +- Old planner behavior unchanged except: save/load actually persist (Task 7). + +--- + +### Task 1: Schema v100 — three plan tables + migration + +**Files:** +- Modify: `lib/core/database/database.dart` (table classes near the checklist tables ~line 130-198 region; `@DriftDatabase` list :1769; `currentSchemaVersion` :1847; `migrationVersions` :1852; `onUpgrade` chain end ~:4624; `beforeOpen` backstop ~:4626) +- Test: `test/core/database/migration_v100_dive_plans_test.dart` + +**Interfaces:** +- Produces: Drift tables `DivePlans`, `DivePlanTanks`, `DivePlanSegments`; generated data classes `DivePlan`, `DivePlanTank`, `DivePlanSegment` (+`Companion`s, `.fromJson/.toJson/.toCompanion`). Later tasks import these from `database.dart` and alias domain entities `as domain`. + +- [ ] **Step 1: Write the failing migration test** + +Model on `test/core/database/migration_v98_checklists_test.dart` (read it first, copy its structure): open `NativeDatabase.memory(setup: (raw) { raw.execute('PRAGMA user_version = 99'); /* minimal parents: divers, dives, dive_sites tables as the v98 test does for its parents */ })`, then open `AppDatabase`, then for each of `dive_plans`, `dive_plan_tanks`, `dive_plan_segments` assert via `PRAGMA table_info(...)`: +- `dive_plans` has columns: `id`, `diver_id`, `name`, `notes`, `mode`, `site_id`, `source_dive_id`, `linked_dive_id`, `altitude`, `water_type`, `gf_low`, `gf_high`, `descent_rate`, `ascent_rate`, `last_stop_depth`, `gas_switch_stop_seconds`, `air_break_o2_seconds`, `air_break_break_seconds`, `sac_bottom`, `sac_deco`, `sac_stressed`, `reserve_pressure`, `surface_interval_seconds`, `setpoint_low`, `setpoint_high`, `setpoint_switch_depth`, `deviation_depth_delta`, `deviation_time_minutes`, `turn_pressure_rule`, `turn_pressure_fraction`, `summary_max_depth`, `summary_runtime_seconds`, `summary_tts_seconds`, `created_at`, `updated_at`, `hlc` +- `dive_plan_tanks` has: `id`, `plan_id`, `name`, `volume`, `working_pressure`, `start_pressure`, `gas_o2`, `gas_he`, `role`, `material`, `preset_name`, `sort_order`, `created_at`, `updated_at`, `hlc` +- `dive_plan_segments` has: `id`, `plan_id`, `type`, `start_depth`, `end_depth`, `duration_seconds`, `tank_id`, `gas_o2`, `gas_he`, `rate`, `switch_to_tank_id`, `sort_order`, `created_at`, `updated_at`, `hlc` +- indexes `idx_dive_plan_tanks_plan_id` and `idx_dive_plan_segments_plan_id` exist +- include the "recovers databases stranded at v99" collision test from the v98 model (set user_version 100 with tables missing → beforeOpen re-assert heals). + +Run: `flutter test test/core/database/migration_v100_dive_plans_test.dart` — expected FAIL (tables missing). + +- [ ] **Step 2: Add the three Drift table classes** (place after the checklist tables): + +```dart +/// Saved dive plans (Phase 2 of the planner redesign). +class DivePlans extends Table { + TextColumn get id => text()(); + TextColumn get diverId => text().nullable().references(Divers, #id)(); + TextColumn get name => text()(); + TextColumn get notes => text().withDefault(const Constant(''))(); + + /// PlanMode enum name: 'oc' | 'ccr'. + TextColumn get mode => text().withDefault(const Constant('oc'))(); + TextColumn get siteId => text().nullable().references(DiveSites, #id)(); + + /// Tissue-seeding source dive (repetitive planning, Phase 6). + TextColumn get sourceDiveId => text().nullable().references(Dives, #id)(); + + /// Executed dive this plan is linked to (plan-vs-actual, Phase 6). + TextColumn get linkedDiveId => text().nullable().references(Dives, #id)(); + RealColumn get altitude => real().nullable()(); + + /// WaterType enum name; null = unspecified (EN13319 density). + TextColumn get waterType => text().nullable()(); + IntColumn get gfLow => integer()(); + IntColumn get gfHigh => integer()(); + RealColumn get descentRate => real().withDefault(const Constant(18.0))(); + RealColumn get ascentRate => real().withDefault(const Constant(9.0))(); + RealColumn get lastStopDepth => real().withDefault(const Constant(3.0))(); + IntColumn get gasSwitchStopSeconds => + integer().withDefault(const Constant(0))(); + + /// Air-break policy; both null = no air breaks. + IntColumn get airBreakO2Seconds => integer().nullable()(); + IntColumn get airBreakBreakSeconds => integer().nullable()(); + RealColumn get sacBottom => real().withDefault(const Constant(15.0))(); + + /// Null = derive 0.8x / 2.5x of sacBottom. + RealColumn get sacDeco => real().nullable()(); + RealColumn get sacStressed => real().nullable()(); + RealColumn get reservePressure => real().withDefault(const Constant(50.0))(); + IntColumn get surfaceIntervalSeconds => integer().nullable()(); + + /// CCR setpoints (Phase 4 UI; persisted now to avoid a later migration). + RealColumn get setpointLow => real().nullable()(); + RealColumn get setpointHigh => real().nullable()(); + RealColumn get setpointSwitchDepth => real().nullable()(); + + /// Contingency config (Phase 5 UI). + RealColumn get deviationDepthDelta => + real().withDefault(const Constant(5.0))(); + IntColumn get deviationTimeMinutes => + integer().withDefault(const Constant(5))(); + + /// TurnPressureRule enum name; null = none. + TextColumn get turnPressureRule => text().nullable()(); + RealColumn get turnPressureFraction => real().nullable()(); + + /// Denormalized list-display summary (no engine run per list row). + RealColumn get summaryMaxDepth => real().nullable()(); + IntColumn get summaryRuntimeSeconds => integer().nullable()(); + IntColumn get summaryTtsSeconds => integer().nullable()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + /// Hybrid Logical Clock for cross-device conflict resolution + /// (nullable: rows written before HLC rollout fall back to updatedAt). + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; +} + +/// Tanks carried on a saved dive plan. +class DivePlanTanks extends Table { + TextColumn get id => text()(); + TextColumn get planId => text().references(DivePlans, #id)(); + TextColumn get name => text().nullable()(); + RealColumn get volume => real().nullable()(); + RealColumn get workingPressure => real().nullable()(); + RealColumn get startPressure => real().nullable()(); + RealColumn get gasO2 => real().withDefault(const Constant(21.0))(); + RealColumn get gasHe => real().withDefault(const Constant(0.0))(); + + /// TankRole enum name. + TextColumn get role => text().withDefault(const Constant('backGas'))(); + + /// TankMaterial enum name; null = unspecified. + TextColumn get material => text().nullable()(); + TextColumn get presetName => text().nullable()(); + IntColumn get sortOrder => integer().withDefault(const Constant(0))(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; +} + +/// User-authored segments (the bottom portion) of a saved dive plan. +class DivePlanSegments extends Table { + TextColumn get id => text()(); + TextColumn get planId => text().references(DivePlans, #id)(); + + /// SegmentType enum name. + TextColumn get type => text()(); + RealColumn get startDepth => real()(); + RealColumn get endDepth => real()(); + IntColumn get durationSeconds => integer()(); + TextColumn get tankId => text().references(DivePlanTanks, #id)(); + RealColumn get gasO2 => real()(); + RealColumn get gasHe => real()(); + RealColumn get rate => real().nullable()(); + TextColumn get switchToTankId => text().nullable()(); + IntColumn get sortOrder => integer().withDefault(const Constant(0))(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; +} +``` + +NOTE: the generated data class for `DivePlans` is `DivePlan` — it collides with the domain aggregate added in Task 3; domain code uses `as domain` imports per codebase convention (`hide`/alias, see `trip_checklist_repository.dart`). + +- [ ] **Step 3: Register + migrate** + +1. Add `DivePlans, DivePlanTanks, DivePlanSegments` to the `@DriftDatabase(tables: [...])` list. +2. `currentSchemaVersion` → `100`; append `100` to `migrationVersions`. +3. Append the `if (from < 100)` block at the end of `onUpgrade`, using idempotent raw DDL exactly mirroring the v98 checklist block (CREATE TABLE IF NOT EXISTS with the snake_case columns from Step 1's list, then `CREATE INDEX IF NOT EXISTS idx_dive_plan_tanks_plan_id ON dive_plan_tanks(plan_id)` and `idx_dive_plan_segments_plan_id ON dive_plan_segments(plan_id)`), then `if (from < 100) await reportProgress();`. +4. Replace the v99 `beforeOpen` re-assert backstop with a v100 one (same pattern as :4637-4649): re-run the three `CREATE TABLE IF NOT EXISTS` + two `CREATE INDEX IF NOT EXISTS` statements on every open. Keep the v99 re-asserts too (append, don't remove). + +- [ ] **Step 4: Codegen + test** + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter test test/core/database/migration_v100_dive_plans_test.dart +``` +Expected: PASS. Also run one existing migration test to confirm the chain still works: `flutter test test/core/database/migration_v98_checklists_test.dart`. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add lib/core/database/database.dart lib/core/database/database.g.dart test/core/database/migration_v100_dive_plans_test.dart +git commit -m "feat(planner): dive plan tables at schema v100" +``` + +--- + +### Task 2: Sync registration — all 18 sites + +**Files:** +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (14 sites) +- Modify: `lib/core/services/sync/sync_service.dart` (3 sites) +- Modify: `lib/core/data/repositories/sync_repository.dart` (1 site) +- Test: `test/core/services/sync/dive_plan_sync_round_trip_test.dart` + +**Interfaces:** +- Produces: full sync participation for entity types `divePlans`, `divePlanTanks`, `divePlanSegments`. Task 4's repository relies on `markRecordPending`/`logDeletion` working for these types. + +- [ ] **Step 1: Serializer — 14 sites** in `sync_data_serializer.dart`, each copying the adjacent checklist entries verbatim with the new names (order everywhere: plans → tanks → segments, parent before child; tanks BEFORE segments because segments FK tanks): + +1. Fields (~:235): `final List> divePlans;` + `divePlanTanks` + `divePlanSegments` +2. Constructor defaults (~:281): `this.divePlans = const [],` (x3) +3. `toJson()` (~:328): `'divePlans': divePlans,` (x3) +4. `fromJson()` (~:376): `divePlans: _parseList(json['divePlans']),` (x3) +5. `_baseTables` (~:566): `(key: 'divePlans', table: _db.divePlans, blob: false, full: null),` then `divePlanTanks`, then `divePlanSegments` +6. `_buildSyncData` (~:941): `divePlans: await _safeExport('divePlans', () => _exportDivePlans(hlcSince)),` (x3) +7. `fetchRecord` switch (~:1257): three cases mirroring checklists +8. `fetchRecords` switch (~:1457): three cases +9. `upsertRecord` switch (~:1699): `case 'divePlans': await _db.into(_db.divePlans).insertOnConflictUpdate(DivePlan.fromJson(data).toCompanion(false)); return;` (x3 — data classes `DivePlan`/`DivePlanTank`/`DivePlanSegment`) +10. `upsertRecords` batch switch (~:2077): three cases via `insertAllOnConflictUpdate` +11. `recordIdsFor` (~:2373): `return plain(_db.divePlans, _db.divePlans.id);` (x3) +12. `_syncTableFor` (~:2496): three cases +13. `deleteRecord` switch (~:2685): three cases +14. Export helpers (~:3085), one per table on the checklist template: + +```dart +Future>> _exportDivePlans(String? hlcSince) async { + final query = _db.select(_db.divePlans); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + return rows.map((r) => r.toJson()).toList(); +} +``` + +- [ ] **Step 2: sync_service.dart — 3 sites** + +15. `mergeOrder` (~:835), AFTER the checklist entries: +```dart +(type: 'divePlans', records: data.divePlans, hasUpdatedAt: true), +(type: 'divePlanTanks', records: data.divePlanTanks, hasUpdatedAt: true), +(type: 'divePlanSegments', records: data.divePlanSegments, hasUpdatedAt: true), +``` +16. `entityHasUpdatedAt` map (~:1439): the same three keys, `true`. +17. `parentRefs` (~:1553): +```dart +'divePlans': [ + (field: 'siteId', parent: 'diveSites', nullable: true), + (field: 'sourceDiveId', parent: 'dives', nullable: true), + (field: 'linkedDiveId', parent: 'dives', nullable: true), +], +'divePlanTanks': [(field: 'planId', parent: 'divePlans', nullable: false)], +'divePlanSegments': [ + (field: 'planId', parent: 'divePlans', nullable: false), + (field: 'tankId', parent: 'divePlanTanks', nullable: false), +], +``` +Check what the checklists did for `diverId` (whether divers refs are required) and mirror; `sync_parent_refs_completeness_test.dart` is the arbiter — run it and add exactly what it demands (parent key names must match its conventions, e.g. `diveSites` vs `dive_sites`; copy from existing entries). + +- [ ] **Step 3: sync_repository.dart — site 18**, `_hlcTargets` (~:38): +```dart +'divePlans': (table: 'dive_plans', pk: 'id'), +'divePlanTanks': (table: 'dive_plan_tanks', pk: 'id'), +'divePlanSegments': (table: 'dive_plan_segments', pk: 'id'), +``` + +- [ ] **Step 4: Structural tests + FK-ON round trip** + +Run the safety-net tests: +```bash +flutter test test/core/services/sync/sync_base_streaming_parity_test.dart test/core/services/sync/sync_parent_refs_completeness_test.dart test/core/services/sync/sync_data_serializer_record_ids_test.dart test/core/services/sync/sync_extra_entities_round_trip_test.dart +``` +Fix whatever they flag. Then write `test/core/services/sync/dive_plan_sync_round_trip_test.dart` modeled on `checklist_sync_round_trip_test.dart` (FK ON via `setUpTestDatabase()`): insert a plan + 2 tanks + 3 segments directly via Drift companions (real FK chain), export via `SyncDataSerializer().exportData(...)`, assert counts, JSON round-trip through `SyncData.fromJson(jsonDecode(jsonEncode(...)))`, re-assert; then `upsertRecord` each into a second in-memory DB and verify rows land (FK ON — insertion order plans → tanks → segments). + +Run: the new test — expected PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add lib/core/services/sync/sync_data_serializer.dart lib/core/services/sync/sync_service.dart lib/core/data/repositories/sync_repository.dart test/core/services/sync/dive_plan_sync_round_trip_test.dart +git commit -m "feat(sync): register dive plan tables across the sync pipeline" +``` + +--- + +### Task 3: Domain aggregate — `domain.DivePlan` + +**Files:** +- Create: `lib/features/planner/domain/entities/dive_plan.dart` +- Test: `test/features/planner/dive_plan_entity_test.dart` + +**Interfaces:** +- Consumes: `PlanSegment` (from `dive_planner/domain/entities/plan_segment.dart` — reused, NOT duplicated), `DiveTank`/`GasMix` (from `dive_log/domain/entities/dive.dart`), `WaterType`/`TankRole` enums, `AirBreakPolicy` (engine type, reused). +- Produces (Tasks 4-7 use these exact names): + +```dart +enum PlanMode { oc, ccr } +enum TurnPressureRule { allUsable, halves, thirds, custom } +class DivePlan extends Equatable { + // identity/meta + final String id; final String name; final String notes; + final String? siteId; final DateTime createdAt; final DateTime updatedAt; + // mode + environment + final PlanMode mode; final double? altitude; final WaterType? waterType; + // deco settings + final int gfLow; final int gfHigh; + final double descentRate; final double ascentRate; + final double lastStopDepth; final int gasSwitchStopSeconds; + final AirBreakPolicy? airBreaks; + // gas planning + final double sacBottom; final double? sacDeco; final double? sacStressed; + final double reservePressure; + // repetitive context + final Duration? surfaceInterval; final String? sourceDiveId; + final String? linkedDiveId; + // CCR (Phase 4) + contingency (Phase 5) config — persisted, unused yet + final double? setpointLow; final double? setpointHigh; + final double? setpointSwitchDepth; + final double deviationDepthDelta; final int deviationTimeMinutes; + final TurnPressureRule? turnPressureRule; final double? turnPressureFraction; + // content + final List segments; final List tanks; + // derived defaults + double get sacDecoEffective => sacDeco ?? sacBottom * 0.8; + double get sacStressedEffective => sacStressed ?? sacBottom * 2.5; + double get maxDepth; // max over segments' start/end depth, 0 if empty + // copyWith with clear-flags for every nullable field +} +class DivePlanSummary extends Equatable { + final String id; final String name; final DateTime updatedAt; + final double? maxDepth; final int? runtimeSeconds; final int? ttsSeconds; + final PlanMode mode; +} +``` + +Defaults in the unnamed constructor mirror the DB defaults (mode oc, notes '', rates 18/9, lastStop 3, gasSwitchStopSeconds 0, sacBottom 15, reservePressure 50, deviation 5 m/5 min). + +- [ ] **Step 1: Write failing tests** — construct a `DivePlan`, assert `sacDecoEffective`/`sacStressedEffective` derivation (15 → 12 / 37.5) and explicit override; `maxDepth` from segments; `copyWith` clear-flags null out `airBreaks`, `surfaceInterval`, `sourceDiveId`. Run → FAIL (file missing). + +- [ ] **Step 2: Implement** the entity exactly per the interface block (plain Equatable, no Drift imports). Run tests → PASS. + +- [ ] **Step 3: Commit** + +```bash +dart format . +git add lib/features/planner/ test/features/planner/dive_plan_entity_test.dart +git commit -m "feat(planner): DivePlan domain aggregate" +``` + +--- + +### Task 4: DivePlanRepository + +**Files:** +- Create: `lib/features/planner/data/repositories/dive_plan_repository.dart` +- Create: `lib/features/planner/presentation/providers/plan_repository_providers.dart` +- Test: `test/features/planner/dive_plan_repository_test.dart` + +**Interfaces:** +- Consumes: Drift tables (Task 1), sync hooks (Task 2), `domain.DivePlan` (Task 3). +- Produces: + +```dart +class DivePlanRepository { + Stream watchPlanChanges(); // tableUpdates on all three tables + Future savePlan(domain.DivePlan plan, {PlanSummaryData? summary}); + Future getPlan(String id); // with ordered tanks+segments + Future> getAllPlanSummaries(); // newest first + Future deletePlan(String id); // children first, tombstones per row + Future duplicatePlan(String id); // new ids, "name (copy)" +} +class PlanSummaryData { final double maxDepth; final int runtimeSeconds; final int? ttsSeconds; } +``` + +Follow `trip_checklist_repository.dart` shape exactly: `AppDatabase get _db => DatabaseService.instance.database;`, `SyncRepository()`, `LoggerService.forClass`, `import ... database.dart` with domain entities `as domain` (mind the `DivePlan` name collision: import the DATABASE with `show`/`hide` or alias — mirror how checklists resolved `Trip`). + +**savePlan semantics (sync-safe child diffing):** inside a transaction, upsert the plan row and every current tank/segment row (stable child ids come from the domain objects); collect previously-persisted child ids, and delete rows whose ids are gone. AFTER the transaction commits: `markRecordPending` for the plan and every upserted child, `logDeletion` for every removed child, then one `SyncEventBus.notifyLocalChange()` (transaction discipline per `applyTemplate` in the checklist repo). + +**deletePlan:** delete segments, tanks, then plan (FK order); `logDeletion` for every row of all three types; notify. + +- [ ] **Step 1: Write failing tests** (FK ON via `setUpTestDatabase()` — read `test/helpers/test_database.dart` and an existing repo test for setup): +- save → getPlan round-trips every field (incl. airBreaks, waterType, surfaceInterval, enums) +- getAllPlanSummaries returns saved summary numbers without loading children +- re-save with a removed segment: row gone AND a `deletion_log` row exists for it (`entityType: 'divePlanSegments'`) +- deletePlan removes all rows + writes tombstones for plan/tanks/segments +- duplicatePlan: new ids everywhere, segments' tankId remapped to the NEW tank ids, name suffixed " (copy)" +- sync_records: savePlan marks plan + children pending (query `sync_records` or use SyncRepository API as checklist tests do) + +Run → FAIL. + +- [ ] **Step 2: Implement repository + mappers** (row↔domain: enums by `.name` with `values.byName`, millis↔DateTime, gasO2/gasHe↔`GasMix`, `Duration`↔seconds; segments/tanks ordered by `sortOrder` and written with their list index as `sortOrder`). Providers: `Provider` + `FutureProvider>` with `ref.invalidateSelfWhen(repo.watchPlanChanges())`. + +- [ ] **Step 3: Run tests → PASS, commit** + +```bash +dart format . +git add lib/features/planner/ test/features/planner/dive_plan_repository_test.dart +git commit -m "feat(planner): DivePlanRepository with full sync participation" +``` + +--- + +### Task 5: PlanEngine — outcome types + schedule + tissue timeline + +**Files:** +- Create: `lib/features/planner/domain/entities/plan_outcome.dart` +- Create: `lib/features/planner/domain/services/plan_engine.dart` +- Test: `test/features/planner/plan_engine_schedule_test.dart` + +**Interfaces:** +- Consumes: `DecoModel`/`BuhlmannGf`/`BuhlmannState`/`DecoSegment`/`DecoSchedule`, `SchedulePolicy`/`AirBreakPolicy`, `OpenCircuit`, `DiveEnvironment.forConditions`, `OptimalOcAscentGas`/`AvailableGas`, `O2ToxicityCalculator` (CNS/OTU/MOD), `domain.DivePlan`. +- Produces: + +```dart +enum PlanIssueSeverity { info, warning, alert, critical } +enum PlanIssueType { + ppO2High, ppO2Critical, hypoxicGas, endExceeded, + gasDensityHigh, gasDensityCritical, cnsWarning, cnsCritical, + otuHigh, gasReserveViolation, gasOut, ndlExceededNoDecoGas, +} +class PlanIssue { type, severity, message, atRuntime?, atDepth?, segmentId?, value?, threshold? } +class PlanStop { depthMeters, durationSeconds, airBreakSeconds, gasFO2, gasFHe, tankId?, arrivalRuntimeSeconds } +class SegmentOutcome { segmentId, startRuntime, endRuntime, ndlAtEnd, ceilingAtEnd, ttsAtEnd, cns, otu, maxPpO2 } +class PlanTankUsage { tankId, litersUsed, remainingPressure?, percentUsed, reserveViolation } +class PlanOutcome { + runtimeSeconds, maxDepth, ndlAtBottom, ttsAtBottom, + stops (List), segmentOutcomes (List), + tankUsages, cnsEnd, otuTotal, issues (severity-sorted desc), + endTissue (BuhlmannState), tissueTimeline (List<(int runtimeSeconds, BuhlmannState)>), + bool get isDiveable => no critical issues; +} +class PlanEngineConfig { + ppO2Working = 1.4, ppO2Deco = 1.6, cnsWarningThreshold = 80, + o2Narcotic = true, endLimitMeters = 30.0, otuLimit = 300.0, + // density thresholds live in gas_density.dart (Task 6) +} +class PlanEngine { + PlanEngine({PlanEngineConfig config = const PlanEngineConfig()}); + PlanOutcome compute(domain.DivePlan plan); +} +``` + +**compute() flow (Phase 2 = OC only; `mode == ccr` yields a single critical issue "CCR planning arrives in a later update" and an OC-computed outcome on the diluent-free path — i.e. compute as OC):** +1. `env = DiveEnvironment.forConditions(altitudeMeters: plan.altitude, waterType: plan.waterType)`. +2. `policy = SchedulePolicy(lastStopDepth: plan.lastStopDepth, ascentRate: plan.ascentRate, gasSwitchStopSeconds: plan.gasSwitchStopSeconds, airBreaks: plan.airBreaks)`. +3. `model = BuhlmannGf(gfLow: plan.gfLow/100, gfHigh: plan.gfHigh/100, environment: env, policy: policy)`. +4. `state = model.initial()`; TODO-free repetitive context: if `plan.surfaceInterval != null` the engine accepts an optional `TissueState? startState` parameter on `compute` (Phase 6 feeds it; default null). +5. Per ordered segment: `state = model.applySegment(state, DecoSegment(startDepth, endDepth, durationSeconds), OpenCircuit(fO2: gasMix.o2/100, fHe: gasMix.he/100))`; record `(runtime, state)` into tissueTimeline; SegmentOutcome via `model.ndlSeconds(state, depthMeters: endDepth, breathing: seg gas)`, `model.ceilingMeters(state, currentDepth: endDepth)`, and `model.schedule(...).ttsSeconds` at the segment end; track ndl/tts at the deepest/bottom segment (same rule as PlanCalculatorService: `type == bottom || endDepth >= maxDepth - 0.1`). +6. Ascent gases: `AvailableGas` per tank with `maxPpO2Mod = O2ToxicityCalculator.calculateMod(o2/100, maxPpO2: config.ppO2Deco)`; `schedule = model.schedule(state, currentDepth: lastEndDepth, gases: OptimalOcAscentGas(gases, maxPpO2: config.ppO2Deco))`. +7. Map engine stops → `PlanStop` (gas via `plan.gasForDepth` equivalent: `OptimalOcAscentGas.gasForDepth(stop.depthMeters)`; tankId = carried tank whose mix matches, deepest-MOD tiebreak; arrival runtimes accumulate ascent legs at `plan.ascentRate` like `_buildDecoSchedule` does today). +8. `runtimeSeconds` = segments + stops + ascent legs; `endTissue` = state after applying stops? NO — keep parity with today: endTissueState = state at end of user segments (stops are simulated inside `schedule`). Document that choice. + +- [ ] **Step 1: Write failing tests** +- **Parity test (the pin):** a 45 m / 25 min air plan (descent 18 m/min + bottom + one AL80) run through BOTH `PlanCalculatorService.calculatePlan` (legacy) and `PlanEngine.compute` produces identical stop depths/durations and equal `ttsAtBottom` (BuhlmannGf's default-policy path is bit-compatible with the legacy engine — proven in Phase 1). +- Trimix multi-gas plan (Tx18/45 60 m/25 min + EAN50 + O2 tanks): stops include gas switches; every `PlanStop` at ≤6 m carries fO2 1.0; `arrivalRuntimeSeconds` strictly increases. +- `plan.airBreaks` set → total deco longer than without, and some `PlanStop.airBreakSeconds > 0` (mirrors the Phase 1 schedule_policy test shape). +- `lastStopDepth: 6` → no stop shallower than 6 m. +- tissueTimeline has one entry per segment with increasing runtime. + +Run → FAIL. + +- [ ] **Step 2: Implement** `plan_outcome.dart` + `plan_engine.dart` per the flow. Run tests → PASS. + +- [ ] **Step 3: Commit** + +```bash +dart format . +git add lib/features/planner/domain/ test/features/planner/plan_engine_schedule_test.dart +git commit -m "feat(planner): PlanEngine schedule generation on the DecoModel seam" +``` + +--- + +### Task 6: PlanEngine — consumption + PlanIssues; shared gas-density helper + +**Files:** +- Create: `lib/core/deco/gas_density.dart` +- Modify: `lib/features/dive_log/data/services/profile_analysis_service.dart` (`_calculateDensityCurve` uses the helper) +- Modify: `lib/features/planner/domain/services/plan_engine.dart` +- Test: `test/core/deco/gas_density_test.dart`, `test/features/planner/plan_engine_issues_test.dart` + +**Interfaces:** +- Produces: + +```dart +// lib/core/deco/gas_density.dart +const double gasDensityWarnGPerL = 5.2; +const double gasDensityCriticalGPerL = 6.2; +double gasDensityGPerL({required double fO2, required double fHe, required double ambientPressureBar}); +``` + +- [ ] **Step 1: Extract the density formula.** Read `ProfileAnalysisService._calculateDensityCurve` and move its exact math (molar masses and the molar-volume divisor it uses — do not change the numbers) into `gasDensityGPerL`; make the service call the helper. Compute one pinned vector with python3 using the SAME constants you extracted (e.g. air at 40 m standard env) and write `gas_density_test.dart` asserting it plus `density(air @ >52m) > 5.2`. Run existing analysis tests to prove no drift: `flutter test test/features/dive_log/presentation/providers/profile_analysis_provider_test.dart`. + +- [ ] **Step 2: Consumption in PlanEngine.** Per segment: `liters = sac × minutes × env.pressureAtDepth(avgDepth)` where `sac` is `sacBottom` for descent/bottom segments and `sacDecoEffective` for ascent/deco/safety segments; per stop: `sacDecoEffective × stopMinutes × env.pressureAtDepth(stopDepth)` charged to the stop's matched tank; ascent legs between stops at `sacDecoEffective` on the leg's gas/tank. Remaining pressure per tank via `pressureAfterConsuming` (compressibility, from `gas_compressibility.dart`). Produce `PlanTankUsage` per tank. + +- [ ] **Step 3: Issues.** Emit, severity-sorted (critical > alert > warning > info): +- `ppO2Critical` (> ppO2Deco anywhere) / `ppO2High` (> ppO2Working on a working segment) +- `hypoxicGas`: inspired pO2 < 0.16 bar at any segment start depth on its gas (use `OpenCircuit.inspiredAt(env.pressureAtDepth(depth)).pO2`) +- `endExceeded`: `GasMix.end(depth, o2Narcotic: config.o2Narcotic) > config.endLimitMeters` at a segment's max depth (warning) +- `gasDensityCritical`/`gasDensityHigh` vs the new constants at each segment's max depth on its gas +- `cnsCritical` (>= 100) / `cnsWarning` (>= config threshold); `otuHigh` (> config.otuLimit, warning) — CNS/OTU via `O2ToxicityCalculator` per segment average ppO2 exactly as `PlanCalculatorService` does today, plus deco stops at stop ppO2 +- `gasOut` (remaining <= 0, critical) / `gasReserveViolation` (remaining < plan.reservePressure, alert) +- `ndlExceededNoDecoGas` (alert): deco obligation exists AND no tank with role `deco`/`stage` and fO2 > back-gas fO2 + +- [ ] **Step 4: Write failing tests then implement** (`plan_engine_issues_test.dart`): +- air 66 m plan → `ppO2Critical`; Tx10/70 at 3 m start → `hypoxicGas` +- air 45 m → `endExceeded` (END > 30) and `gasDensityHigh` or `Critical` (air at 45 m: compute classification with python3 from the extracted constants and assert the exact type) +- tiny tank → `gasOut`; barely-insufficient reserve → `gasReserveViolation` +- deco dive with only back gas → `ndlExceededNoDecoGas`; add EAN50 deco tank → issue gone +- issues list sorted by severity descending +- consumption: python3-computed liters for one segment (15 L/min × 20 min × 4.0 bar = 1200 L) matches `PlanTankUsage.litersUsed` for a single-segment plan (no stops case: 30 m/20 min NDL dive... use a short bottom time that yields no deco, e.g. 30 m/10 min, and assert descent+bottom+direct-ascent liters within 1 L of hand-computed) + +Run → PASS after implementation. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add lib/core/deco/gas_density.dart lib/features/dive_log/data/services/profile_analysis_service.dart lib/features/planner/ test/core/deco/gas_density_test.dart test/features/planner/plan_engine_issues_test.dart +git commit -m "feat(planner): PlanEngine consumption and severity-sorted plan issues" +``` + +--- + +### Task 7: Wire save/load into the existing planner + +**Files:** +- Create: `lib/features/planner/domain/services/dive_plan_state_mapper.dart` +- Modify: `lib/features/dive_planner/presentation/providers/dive_planner_providers.dart` (`DivePlanNotifier.markSaved` → real save; add `loadPlanById`) +- Modify: `lib/features/dive_planner/presentation/pages/dive_planner_page.dart` (honor `planId`; remove the save TODO) +- Test: `test/features/planner/dive_plan_state_mapper_test.dart`, `test/features/dive_planner/save_load_round_trip_test.dart` + +**Interfaces:** +- Produces: + +```dart +// dive_plan_state_mapper.dart +// `existing` preserves fields the legacy state doesn't carry (mode, rates, +// contingency/CCR config) across an edit-save cycle; null = defaults. +domain.DivePlan divePlanFromState(DivePlanState state, {domain.DivePlan? existing}); +DivePlanState stateFromDivePlan(domain.DivePlan plan); // isDirty: false +``` + +Mapping notes: `state.gfLow/gfHigh/sacRate/reservePressure/altitude/siteId/notes/name/segments/tanks/surfaceInterval/createdAt/updatedAt` map 1:1 (`sacRate` ↔ `sacBottom`); everything new (mode, rates, lastStop, airBreaks, deviation, setpoints, waterType, sourceDiveId, linkedDiveId) takes the DivePlan constructor defaults / null and round-trips untouched when loading a plan saved by a future phase. + +- [ ] **Step 1: Mapper + failing round-trip test** — state → plan → state equals the original (modulo `isDirty`); plan with non-default Phase-4/5 fields → state → plan preserves them (mapper must carry the original plan through: `stateFromDivePlan` keeps a reference? NO — keep it simple and lossy-safe: `divePlanFromState` accepts an optional `existing` plan whose non-state fields are preserved: `domain.DivePlan divePlanFromState(DivePlanState state, {domain.DivePlan? existing})`). Test both paths. + +- [ ] **Step 2: Notifier wiring.** +- `DivePlanNotifier` gains a `DivePlanRepository` (constructor-injected via its provider) and a `domain.DivePlan? _loaded` field (the `existing` for the mapper). +- `markSaved()` becomes `Future save({PlanSummaryData? summary})`: builds `divePlanFromState(state, existing: _loaded)`, calls `repository.savePlan(plan, summary: summary)`, stores `_loaded = plan`, sets `isDirty: false`. Keep `markSaved()` as a deprecated alias calling `save()` so existing call sites compile. +- New `Future loadPlanById(String id)`: `repository.getPlan(id)` → if found `_loaded = plan; state = stateFromDivePlan(plan); return true`. +- `dive_planner_page.dart`: where the save action shows the TODO snackbar (~line 205), call `await notifier.save(summary: PlanSummaryData(maxDepth: result.maxDepth, runtimeSeconds: result.totalRuntime, ttsSeconds: result.ttsAtBottom))` using the current `planResultsProvider` value, then a success snackbar. In `initState`, if `widget.planId != null`, schedule `Future.microtask(() => notifier.loadPlanById(widget.planId!))` (Riverpod 3 forbids provider mutation during build/dispose — microtask pattern per project memory). + +- [ ] **Step 3: Round-trip test** (`save_load_round_trip_test.dart`, ProviderContainer-level, FK-ON test DB): build a plan in the notifier (addSimplePlan + rename), `await save()`, `newPlan()`, `await loadPlanById(id)` → state segments/tanks/name/gf match the saved ones; `getAllPlanSummaries()` shows the plan with the summary numbers. + +- [ ] **Step 4: Run + commit** + +```bash +flutter test test/features/planner/dive_plan_state_mapper_test.dart test/features/dive_planner/save_load_round_trip_test.dart test/features/dive_planner/presentation/providers/dive_planner_providers_test.dart +dart format . +git add lib/features/planner/ lib/features/dive_planner/ test/features/planner/ test/features/dive_planner/ +git commit -m "feat(planner): persist plans - save and load wired into the planner" +``` + +--- + +### Task 8: Full verification sweep + +- [ ] Run, in order (specific files/dirs per the timeout convention): +```bash +flutter test test/core/database/migration_v100_dive_plans_test.dart +flutter test test/core/services/sync/ # full sync suite — the structural net +flutter test test/features/planner/ test/features/dive_planner/ +flutter test test/core/deco/ +flutter analyze +dart format . +``` +Expected: all green, analyze clean, format no changes. If the full sync directory is too slow for one Bash call, split it alphabetically into 2-3 runs. + +- [ ] Commit anything outstanding, then done. Phase 2 exit criteria: plans persist and sync; PlanEngine produces schedule + consumption + issues from a `domain.DivePlan`; old planner UX unchanged except working save/load. + +## Explicitly out of scope (later phases) + +- New planner UI, saved-plans list page, cutover (Phase 3) +- CCR PlanEngine paths, bailout (Phase 4) — setpoint columns persisted only +- Contingency computation: deviations, lost gas, turn pressure (Phase 5) — config columns persisted only +- Tissue seeding from logged dives / SAC auto-fill / plan-vs-actual / convert-to-dive (Phase 6) — `sourceDiveId`/`linkedDiveId` columns persisted only diff --git a/lib/core/data/repositories/sync_repository.dart b/lib/core/data/repositories/sync_repository.dart index 5f2363693..184a4805d 100644 --- a/lib/core/data/repositories/sync_repository.dart +++ b/lib/core/data/repositories/sync_repository.dart @@ -38,6 +38,9 @@ class SyncRepository { 'checklistTemplates': (table: 'checklist_templates', pk: 'id'), 'checklistTemplateItems': (table: 'checklist_template_items', pk: 'id'), 'tripChecklistItems': (table: 'trip_checklist_items', pk: 'id'), + 'divePlans': (table: 'dive_plans', pk: 'id'), + 'divePlanTanks': (table: 'dive_plan_tanks', pk: 'id'), + 'divePlanSegments': (table: 'dive_plan_segments', pk: 'id'), 'equipment': (table: 'equipment', pk: 'id'), 'equipmentSets': (table: 'equipment_sets', pk: 'id'), 'diveTypes': (table: 'dive_types', pk: 'id'), diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 6acfac09f..3e01bcaf4 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -197,6 +197,137 @@ class TripChecklistItems extends Table { // coverage:ignore-end } +/// Saved dive plans (dive planner redesign, Phase 2) +class DivePlans extends Table { + // coverage:ignore-start + TextColumn get id => text()(); + TextColumn get diverId => text().nullable().references(Divers, #id)(); + TextColumn get name => text()(); + TextColumn get notes => text().withDefault(const Constant(''))(); + + /// PlanMode enum name: 'oc' | 'ccr'. + TextColumn get mode => text().withDefault(const Constant('oc'))(); + TextColumn get siteId => text().nullable().references(DiveSites, #id)(); + + /// Tissue-seeding source dive (repetitive planning, Phase 6). + TextColumn get sourceDiveId => text().nullable().references(Dives, #id)(); + + /// Executed dive this plan is linked to (plan-vs-actual, Phase 6). + TextColumn get linkedDiveId => text().nullable().references(Dives, #id)(); + RealColumn get altitude => real().nullable()(); + + /// WaterType enum name; null = unspecified (EN13319 density). + TextColumn get waterType => text().nullable()(); + IntColumn get gfLow => integer()(); + IntColumn get gfHigh => integer()(); + RealColumn get descentRate => real().withDefault(const Constant(18.0))(); + RealColumn get ascentRate => real().withDefault(const Constant(9.0))(); + RealColumn get lastStopDepth => real().withDefault(const Constant(3.0))(); + IntColumn get gasSwitchStopSeconds => + integer().withDefault(const Constant(0))(); + + /// Air-break policy; both null = no air breaks. + IntColumn get airBreakO2Seconds => integer().nullable()(); + IntColumn get airBreakBreakSeconds => integer().nullable()(); + RealColumn get sacBottom => real().withDefault(const Constant(15.0))(); + + /// Null = derive 0.8x / 2.5x of sacBottom. + RealColumn get sacDeco => real().nullable()(); + RealColumn get sacStressed => real().nullable()(); + RealColumn get reservePressure => real().withDefault(const Constant(50.0))(); + IntColumn get surfaceIntervalSeconds => integer().nullable()(); + + /// CCR setpoints (Phase 4 UI; persisted now to avoid a later migration). + RealColumn get setpointLow => real().nullable()(); + RealColumn get setpointHigh => real().nullable()(); + RealColumn get setpointSwitchDepth => real().nullable()(); + + /// Contingency config (Phase 5 UI). + RealColumn get deviationDepthDelta => + real().withDefault(const Constant(5.0))(); + IntColumn get deviationTimeMinutes => + integer().withDefault(const Constant(5))(); + + /// TurnPressureRule enum name; null = none. + TextColumn get turnPressureRule => text().nullable()(); + RealColumn get turnPressureFraction => real().nullable()(); + + /// Denormalized list-display summary (no engine run per list row). + RealColumn get summaryMaxDepth => real().nullable()(); + IntColumn get summaryRuntimeSeconds => integer().nullable()(); + IntColumn get summaryTtsSeconds => integer().nullable()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + /// Hybrid Logical Clock for cross-device conflict resolution + /// (nullable: rows written before HLC rollout fall back to updatedAt). + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; + // coverage:ignore-end +} + +/// Tanks carried on a saved dive plan +class DivePlanTanks extends Table { + // coverage:ignore-start + TextColumn get id => text()(); + TextColumn get planId => text().references(DivePlans, #id)(); + TextColumn get name => text().nullable()(); + RealColumn get volume => real().nullable()(); + RealColumn get workingPressure => real().nullable()(); + RealColumn get startPressure => real().nullable()(); + RealColumn get gasO2 => real().withDefault(const Constant(21.0))(); + RealColumn get gasHe => real().withDefault(const Constant(0.0))(); + + /// TankRole enum name. + TextColumn get role => text().withDefault(const Constant('backGas'))(); + + /// TankMaterial enum name; null = unspecified. + TextColumn get material => text().nullable()(); + TextColumn get presetName => text().nullable()(); + IntColumn get sortOrder => integer().withDefault(const Constant(0))(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + /// Hybrid Logical Clock for cross-device conflict resolution + /// (nullable: rows written before HLC rollout fall back to updatedAt). + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; + // coverage:ignore-end +} + +/// User-authored segments (the bottom portion) of a saved dive plan +class DivePlanSegments extends Table { + // coverage:ignore-start + TextColumn get id => text()(); + TextColumn get planId => text().references(DivePlans, #id)(); + + /// SegmentType enum name. + TextColumn get type => text()(); + RealColumn get startDepth => real()(); + RealColumn get endDepth => real()(); + IntColumn get durationSeconds => integer()(); + TextColumn get tankId => text().references(DivePlanTanks, #id)(); + RealColumn get gasO2 => real()(); + RealColumn get gasHe => real()(); + RealColumn get rate => real().nullable()(); + TextColumn get switchToTankId => text().nullable()(); + IntColumn get sortOrder => integer().withDefault(const Constant(0))(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + /// Hybrid Logical Clock for cross-device conflict resolution + /// (nullable: rows written before HLC rollout fall back to updatedAt). + TextColumn get hlc => text().nullable()(); + + @override + Set get primaryKey => {id}; + // coverage:ignore-end +} + /// Dive log entries class Dives extends Table { TextColumn get id => text()(); @@ -1825,6 +1956,10 @@ class FieldPresets extends Table { ChecklistTemplates, ChecklistTemplateItems, TripChecklistItems, + // Saved dive plans (planner redesign Phase 2) + DivePlans, + DivePlanTanks, + DivePlanSegments, // CSV import presets (local-only) CsvPresets, // Column view configuration @@ -1844,7 +1979,7 @@ class AppDatabase extends _$AppDatabase { /// The current schema version as a static constant so that pre-open checks /// (e.g. version-mismatch guard) can reference it without an instance. - static const int currentSchemaVersion = 99; + static const int currentSchemaVersion = 100; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -1947,6 +2082,7 @@ class AppDatabase extends _$AppDatabase { 97, 98, 99, + 100, ]; /// Tables that carry a per-row Hybrid Logical Clock for cross-device conflict @@ -4622,6 +4758,24 @@ class AppDatabase extends _$AppDatabase { await m.createTable(buddyRoles); } if (from < 99) await reportProgress(); + if (from < 100) { + // Saved dive plans (planner redesign Phase 2): three synced tables. + // createTable is IF NOT EXISTS and the indexes are guarded, so this + // block is idempotent; the beforeOpen backstop re-asserts the same + // objects against schema-version collisions. + await m.createTable(divePlans); + await m.createTable(divePlanTanks); + await m.createTable(divePlanSegments); + await customStatement(''' + CREATE INDEX IF NOT EXISTS idx_dive_plan_tanks_plan_id + ON dive_plan_tanks(plan_id) + '''); + await customStatement(''' + CREATE INDEX IF NOT EXISTS idx_dive_plan_segments_plan_id + ON dive_plan_segments(plan_id) + '''); + } + if (from < 100) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -4647,6 +4801,20 @@ class AppDatabase extends _$AppDatabase { ); } await createMigrator().createTable(buddyRoles); + + // v100 backstop: re-assert the dive plan tables and their indexes + // (same collision disease; all DDL idempotent). + await createMigrator().createTable(divePlans); + await createMigrator().createTable(divePlanTanks); + await createMigrator().createTable(divePlanSegments); + await customStatement(''' + CREATE INDEX IF NOT EXISTS idx_dive_plan_tanks_plan_id + ON dive_plan_tanks(plan_id) + '''); + await customStatement(''' + CREATE INDEX IF NOT EXISTS idx_dive_plan_segments_plan_id + ON dive_plan_segments(plan_id) + '''); }, ); } diff --git a/lib/core/deco/gas_density.dart b/lib/core/deco/gas_density.dart new file mode 100644 index 000000000..48f896372 --- /dev/null +++ b/lib/core/deco/gas_density.dart @@ -0,0 +1,34 @@ +/// Gas density at depth (work-of-breathing limit checks). +/// +/// High breathing-gas density increases CO2 retention risk. Community +/// guidance (Anthony & Mitchell): keep density at or below 5.2 g/L; 6.2 g/L +/// is the hard ceiling. +library; + +/// Recommended maximum gas density (g/L). +const double gasDensityWarnGPerL = 5.2; + +/// Hard maximum gas density (g/L). +const double gasDensityCriticalGPerL = 6.2; + +/// Molecular weights (g/mol) and molar volume shared with the dive-details +/// density curve — keep these in lockstep with any display of density. +const double _o2MolWeight = 32.0; +const double _n2MolWeight = 28.0; +const double _heMolWeight = 4.0; +const double _molarVolumeLPerMol = 24.04; // L/mol at STP + +/// Density in g/L of a breathing gas at [ambientPressureBar]. +/// +/// Formula: ambient x sum(fraction x molecular weight) / 24.04, with +/// fN2 = 1 - fO2 - fHe. +double gasDensityGPerL({ + required double fO2, + required double fHe, + required double ambientPressureBar, +}) { + final fN2 = 1.0 - fO2 - fHe; + final avgMolWeight = + (fO2 * _o2MolWeight) + (fN2 * _n2MolWeight) + (fHe * _heMolWeight); + return avgMolWeight / _molarVolumeLPerMol * ambientPressureBar; +} diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index ae0354b84..d0ff291bc 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -235,6 +235,9 @@ class SyncData { final List> checklistTemplates; final List> checklistTemplateItems; final List> tripChecklistItems; + final List> divePlans; + final List> divePlanTanks; + final List> divePlanSegments; final List> tags; final List> diveTags; final List> diveDiveTypes; @@ -281,6 +284,9 @@ class SyncData { this.checklistTemplates = const [], this.checklistTemplateItems = const [], this.tripChecklistItems = const [], + this.divePlans = const [], + this.divePlanTanks = const [], + this.divePlanSegments = const [], this.tags = const [], this.diveTags = const [], this.diveDiveTypes = const [], @@ -328,6 +334,9 @@ class SyncData { 'checklistTemplates': checklistTemplates, 'checklistTemplateItems': checklistTemplateItems, 'tripChecklistItems': tripChecklistItems, + 'divePlans': divePlans, + 'divePlanTanks': divePlanTanks, + 'divePlanSegments': divePlanSegments, 'tags': tags, 'diveTags': diveTags, 'diveDiveTypes': diveDiveTypes, @@ -376,6 +385,9 @@ class SyncData { checklistTemplates: _parseList(json['checklistTemplates']), checklistTemplateItems: _parseList(json['checklistTemplateItems']), tripChecklistItems: _parseList(json['tripChecklistItems']), + divePlans: _parseList(json['divePlans']), + divePlanTanks: _parseList(json['divePlanTanks']), + divePlanSegments: _parseList(json['divePlanSegments']), tags: _parseList(json['tags']), diveTags: _parseList(json['diveTags']), diveDiveTypes: _parseList(json['diveDiveTypes']), @@ -581,6 +593,14 @@ class SyncDataSerializer { blob: false, full: null, ), + (key: 'divePlans', table: _db.divePlans, blob: false, full: null), + (key: 'divePlanTanks', table: _db.divePlanTanks, blob: false, full: null), + ( + key: 'divePlanSegments', + table: _db.divePlanSegments, + blob: false, + full: null, + ), (key: 'tags', table: _db.tags, blob: false, full: null), (key: 'diveTags', table: _db.diveTags, blob: false, full: null), (key: 'diveDiveTypes', table: _db.diveDiveTypes, blob: false, full: null), @@ -950,6 +970,18 @@ class SyncDataSerializer { 'tripChecklistItems', () => _exportTripChecklistItems(hlcSince), ), + divePlans: await _safeExport( + 'divePlans', + () => _exportDivePlans(hlcSince), + ), + divePlanTanks: await _safeExport( + 'divePlanTanks', + () => _exportDivePlanTanks(hlcSince), + ), + divePlanSegments: await _safeExport( + 'divePlanSegments', + () => _exportDivePlanSegments(hlcSince), + ), tags: await _safeExport('tags', () => _exportTags(hlcSince)), diveTags: await _safeExport('diveTags', () => _exportDiveTags(hlcSince)), diveDiveTypes: await _safeExport( @@ -1269,6 +1301,21 @@ class SyncDataSerializer { _db.tripChecklistItems, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); return row?.toJson(); + case 'divePlans': + final row = await (_db.select( + _db.divePlans, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(); + case 'divePlanTanks': + final row = await (_db.select( + _db.divePlanTanks, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(); + case 'divePlanSegments': + final row = await (_db.select( + _db.divePlanSegments, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(); case 'tags': final row = await (_db.select( _db.tags, @@ -1469,6 +1516,21 @@ class SyncDataSerializer { _db.tripChecklistItems, )..where((t) => t.id.isIn(idList))).get(); return {for (final r in rows) r.id: r.toJson()}; + case 'divePlans': + final rows = await (_db.select( + _db.divePlans, + )..where((t) => t.id.isIn(idList))).get(); + return {for (final r in rows) r.id: r.toJson()}; + case 'divePlanTanks': + final rows = await (_db.select( + _db.divePlanTanks, + )..where((t) => t.id.isIn(idList))).get(); + return {for (final r in rows) r.id: r.toJson()}; + case 'divePlanSegments': + final rows = await (_db.select( + _db.divePlanSegments, + )..where((t) => t.id.isIn(idList))).get(); + return {for (final r in rows) r.id: r.toJson()}; case 'diveTypes': final rows = await (_db.select( _db.diveTypes, @@ -1717,6 +1779,25 @@ class SyncDataSerializer { TripChecklistItem.fromJson(data).toCompanion(false), ); return; + case 'divePlans': + await _db + .into(_db.divePlans) + .insertOnConflictUpdate(DivePlan.fromJson(data).toCompanion(false)); + return; + case 'divePlanTanks': + await _db + .into(_db.divePlanTanks) + .insertOnConflictUpdate( + DivePlanTank.fromJson(data).toCompanion(false), + ); + return; + case 'divePlanSegments': + await _db + .into(_db.divePlanSegments) + .insertOnConflictUpdate( + DivePlanSegment.fromJson(data).toCompanion(false), + ); + return; case 'tags': await _db .into(_db.tags) @@ -2106,6 +2187,36 @@ class SyncDataSerializer { ), ); return; + case 'divePlans': + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.divePlans, + records + .map((r) => DivePlan.fromJson(r).toCompanion(false)) + .toList(), + ), + ); + return; + case 'divePlanTanks': + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.divePlanTanks, + records + .map((r) => DivePlanTank.fromJson(r).toCompanion(false)) + .toList(), + ), + ); + return; + case 'divePlanSegments': + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.divePlanSegments, + records + .map((r) => DivePlanSegment.fromJson(r).toCompanion(false)) + .toList(), + ), + ); + return; case 'tags': await _db.batch( (b) => b.insertAllOnConflictUpdate( @@ -2376,6 +2487,12 @@ class SyncDataSerializer { return plain(_db.checklistTemplateItems, _db.checklistTemplateItems.id); case 'tripChecklistItems': return plain(_db.tripChecklistItems, _db.tripChecklistItems.id); + case 'divePlans': + return plain(_db.divePlans, _db.divePlans.id); + case 'divePlanTanks': + return plain(_db.divePlanTanks, _db.divePlanTanks.id); + case 'divePlanSegments': + return plain(_db.divePlanSegments, _db.divePlanSegments.id); case 'equipment': return plain(_db.equipment, _db.equipment.id); case 'equipmentSets': @@ -2499,6 +2616,12 @@ class SyncDataSerializer { return _db.checklistTemplateItems; case 'tripChecklistItems': return _db.tripChecklistItems; + case 'divePlans': + return _db.divePlans; + case 'divePlanTanks': + return _db.divePlanTanks; + case 'divePlanSegments': + return _db.divePlanSegments; case 'equipment': return _db.equipment; case 'equipmentSets': @@ -2697,6 +2820,21 @@ class SyncDataSerializer { _db.tripChecklistItems, )..where((t) => t.id.equals(recordId))).go(); return; + case 'divePlans': + await (_db.delete( + _db.divePlans, + )..where((t) => t.id.equals(recordId))).go(); + return; + case 'divePlanTanks': + await (_db.delete( + _db.divePlanTanks, + )..where((t) => t.id.equals(recordId))).go(); + return; + case 'divePlanSegments': + await (_db.delete( + _db.divePlanSegments, + )..where((t) => t.id.equals(recordId))).go(); + return; case 'tags': await (_db.delete(_db.tags)..where((t) => t.id.equals(recordId))).go(); return; @@ -3115,6 +3253,37 @@ class SyncDataSerializer { return rows.map((r) => r.toJson()).toList(); } + Future>> _exportDivePlans(String? hlcSince) async { + final query = _db.select(_db.divePlans); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + return rows.map((r) => r.toJson()).toList(); + } + + Future>> _exportDivePlanTanks( + String? hlcSince, + ) async { + final query = _db.select(_db.divePlanTanks); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + return rows.map((r) => r.toJson()).toList(); + } + + Future>> _exportDivePlanSegments( + String? hlcSince, + ) async { + final query = _db.select(_db.divePlanSegments); + if (hlcSince != null) { + query.where((t) => t.hlc.isBiggerThanValue(hlcSince)); + } + final rows = await query.get(); + return rows.map((r) => r.toJson()).toList(); + } + Future>> _exportTags(String? hlcSince) async { final query = _db.select(_db.tags); if (hlcSince != null) { diff --git a/lib/core/services/sync/sync_service.dart b/lib/core/services/sync/sync_service.dart index dbd66917e..320bcfbb7 100644 --- a/lib/core/services/sync/sync_service.dart +++ b/lib/core/services/sync/sync_service.dart @@ -847,6 +847,17 @@ class SyncService { records: data.tripChecklistItems, hasUpdatedAt: true, ), + (type: 'divePlans', records: data.divePlans, hasUpdatedAt: true), + ( + type: 'divePlanTanks', + records: data.divePlanTanks, + hasUpdatedAt: true, + ), + ( + type: 'divePlanSegments', + records: data.divePlanSegments, + hasUpdatedAt: true, + ), (type: 'equipment', records: data.equipment, hasUpdatedAt: true), ( type: 'equipmentSets', @@ -1439,6 +1450,9 @@ class SyncService { 'checklistTemplates': true, 'checklistTemplateItems': true, 'tripChecklistItems': true, + 'divePlans': true, + 'divePlanTanks': true, + 'divePlanSegments': true, 'equipment': true, 'equipmentSets': true, 'equipmentSetItems': false, @@ -1554,6 +1568,16 @@ class SyncService { (field: 'templateId', parent: 'checklistTemplates', nullable: false), ], 'tripChecklistItems': [(field: 'tripId', parent: 'trips', nullable: false)], + 'divePlans': [ + (field: 'siteId', parent: 'diveSites', nullable: true), + (field: 'sourceDiveId', parent: 'dives', nullable: true), + (field: 'linkedDiveId', parent: 'dives', nullable: true), + ], + 'divePlanTanks': [(field: 'planId', parent: 'divePlans', nullable: false)], + 'divePlanSegments': [ + (field: 'planId', parent: 'divePlans', nullable: false), + (field: 'tankId', parent: 'divePlanTanks', nullable: false), + ], 'certifications': [ (field: 'courseId', parent: 'courses', nullable: true), (field: 'instructorId', parent: 'buddies', nullable: true), diff --git a/lib/features/dive_log/data/services/profile_analysis_service.dart b/lib/features/dive_log/data/services/profile_analysis_service.dart index 495255895..f26a36bfc 100644 --- a/lib/features/dive_log/data/services/profile_analysis_service.dart +++ b/lib/features/dive_log/data/services/profile_analysis_service.dart @@ -13,6 +13,7 @@ import 'package:submersion/core/deco/entities/dive_environment.dart'; import 'package:submersion/core/deco/entities/o2_exposure.dart'; import 'package:submersion/core/deco/entities/profile_gas_segment.dart'; import 'package:submersion/core/deco/entities/tissue_compartment.dart'; +import 'package:submersion/core/deco/gas_density.dart'; import 'package:submersion/core/deco/o2_toxicity_calculator.dart'; import 'package:submersion/core/deco/scr_calculator.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart' @@ -1686,21 +1687,13 @@ class ProfileAnalysisService { required List n2Fractions, required List heFractions, }) { - // Average molecular weight of gas mix - const o2MolWeight = 32.0; - const n2MolWeight = 28.0; - const heMolWeight = 4.0; - const molarVolume = 24.04; // L/mol at STP - return List.generate(depths.length, (i) { - final avgMolWeight = - (o2Fractions[i] * o2MolWeight) + - (n2Fractions[i] * n2MolWeight) + - (heFractions[i] * heMolWeight); - final surfaceDensity = avgMolWeight / molarVolume; - final depth = depths[i]; - final ambientPressure = 1.0 + (depth / 10.0); - return surfaceDensity * ambientPressure; + final ambientPressure = 1.0 + (depths[i] / 10.0); + return gasDensityGPerL( + fO2: o2Fractions[i], + fHe: heFractions[i], + ambientPressureBar: ambientPressure, + ); }); } diff --git a/lib/features/dive_planner/presentation/pages/dive_planner_page.dart b/lib/features/dive_planner/presentation/pages/dive_planner_page.dart index 2ef4cfef5..df6875d20 100644 --- a/lib/features/dive_planner/presentation/pages/dive_planner_page.dart +++ b/lib/features/dive_planner/presentation/pages/dive_planner_page.dart @@ -10,6 +10,7 @@ import 'package:submersion/features/dive_planner/presentation/widgets/plan_setti import 'package:submersion/features/dive_planner/presentation/widgets/plan_tank_list.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/segment_list.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/simple_plan_dialog.dart'; +import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; /// Main dive planner page with three tabs: Plan, Results, Profile. /// @@ -50,6 +51,16 @@ class _DivePlannerPageState extends ConsumerState ref.read(plannerTabIndexProvider.notifier).state = _tabController.index; } }); + + // Load a persisted plan when routed with an id (microtask: Riverpod 3 + // forbids provider mutation during widget lifecycle callbacks). + final planId = widget.planId; + if (planId != null) { + Future.microtask(() { + if (!mounted) return; + ref.read(divePlanNotifierProvider.notifier).loadPlanById(planId); + }); + } } @override @@ -201,9 +212,18 @@ class _DivePlannerPageState extends ConsumerState ); } - void _savePlan() { - // TODO: Implement save to database - ref.read(divePlanNotifierProvider.notifier).markSaved(); + Future _savePlan() async { + final results = ref.read(planResultsProvider); + await ref + .read(divePlanNotifierProvider.notifier) + .save( + summary: PlanSummaryData( + maxDepth: results.maxDepth, + runtimeSeconds: results.totalRuntime, + ttsSeconds: results.ttsAtBottom, + ), + ); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.divePlanner_message_planSaved)), ); diff --git a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart index 1ccd49df2..4274eda03 100644 --- a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart +++ b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart @@ -9,6 +9,11 @@ import 'package:submersion/features/settings/presentation/providers/settings_pro import 'package:submersion/features/dive_planner/data/services/plan_calculator_service.dart'; import 'package:submersion/features/dive_planner/domain/entities/plan_result.dart'; import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/services/dive_plan_state_mapper.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_repository_providers.dart'; const _uuid = Uuid(); @@ -41,13 +46,20 @@ final planCalculatorServiceProvider = Provider((ref) { class DivePlanNotifier extends StateNotifier { final PlanCalculatorService _calculator; final double Function() _getDefaultReservePressure; + final DivePlanRepository? _repository; + + /// The persisted aggregate this state was loaded from (or last saved as); + /// preserves fields the legacy state does not carry across a save cycle. + domain.DivePlan? _loaded; DivePlanNotifier( this._calculator, { double reservePressure = DivePlanState.kDefaultReservePressureBar, double Function()? getDefaultReservePressure, + DivePlanRepository? repository, }) : _getDefaultReservePressure = getDefaultReservePressure ?? (() => reservePressure), + _repository = repository, super(_createInitialState(reservePressure: reservePressure)); static DivePlanState _createInitialState({ @@ -84,6 +96,7 @@ class DivePlanNotifier extends StateNotifier { /// Reset to a new empty plan. void newPlan() { + _loaded = null; state = _createInitialState(reservePressure: _getDefaultReservePressure()); } @@ -92,6 +105,17 @@ class DivePlanNotifier extends StateNotifier { state = plan; } + /// Load a persisted plan by id. Returns false when it does not exist. + Future loadPlanById(String planId) async { + final repository = _repository; + if (repository == null) return false; + final plan = await repository.getPlan(planId); + if (plan == null || !mounted) return false; + _loaded = plan; + state = stateFromDivePlan(plan); + return true; + } + /// Update plan name. void updateName(String name) { state = state.copyWith( @@ -340,7 +364,22 @@ class DivePlanNotifier extends StateNotifier { // Persistence // -------------------------------------------------------------------------- - /// Mark the plan as saved. + /// Persist the current plan (and its summary numbers for the list view). + Future save({PlanSummaryData? summary}) async { + final repository = _repository; + if (repository == null) { + state = state.copyWith(isDirty: false); + return; + } + final plan = divePlanFromState(state, existing: _loaded); + await repository.savePlan(plan, summary: summary); + _loaded = plan; + if (mounted) { + state = state.copyWith(isDirty: false); + } + } + + /// Mark the plan as saved without persisting (legacy path; prefer [save]). void markSaved() { state = state.copyWith(isDirty: false); } @@ -398,6 +437,7 @@ final divePlanNotifierProvider = calculator, reservePressure: defaultReserve(), getDefaultReservePressure: defaultReserve, + repository: ref.watch(divePlanRepositoryProvider), ); }); diff --git a/lib/features/planner/data/repositories/dive_plan_repository.dart b/lib/features/planner/data/repositories/dive_plan_repository.dart new file mode 100644 index 000000000..40a5c08f3 --- /dev/null +++ b/lib/features/planner/data/repositories/dive_plan_repository.dart @@ -0,0 +1,489 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart' as db; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; + +/// Denormalized summary numbers written on save so the saved-plans list +/// renders without running the engine per row. +class PlanSummaryData { + final double maxDepth; + final int runtimeSeconds; + final int? ttsSeconds; + + const PlanSummaryData({ + required this.maxDepth, + required this.runtimeSeconds, + this.ttsSeconds, + }); +} + +/// Persistence for saved dive plans (plan + tanks + segments), with full +/// sync participation: every write marks records pending with a fresh HLC, +/// every delete writes a per-row tombstone. +class DivePlanRepository { + db.AppDatabase get _db => DatabaseService.instance.database; + final SyncRepository _syncRepository = SyncRepository(); + final _uuid = const Uuid(); + final _log = LoggerService.forClass(DivePlanRepository); + + /// Fires on any change to the three plan tables. + Stream watchPlanChanges() => _db.tableUpdates( + TableUpdateQuery.onAllTables([ + _db.divePlans, + _db.divePlanTanks, + _db.divePlanSegments, + ]), + ); + + /// Upserts the plan and its children; children removed since the last save + /// are deleted WITH per-row tombstones (parent-surviving child deletes + /// must tombstone individually or other devices resurrect them). + Future savePlan( + domain.DivePlan plan, { + PlanSummaryData? summary, + }) async { + final now = DateTime.now().millisecondsSinceEpoch; + final removedTankIds = []; + final removedSegmentIds = []; + + try { + await _db.transaction(() async { + // Keep the existing rows so their original createdAt survives an edit + // (only updatedAt should move); other synced child tables do the same. + final existingTankRows = await (_db.select( + _db.divePlanTanks, + )..where((t) => t.planId.equals(plan.id))).get(); + final existingSegmentRows = await (_db.select( + _db.divePlanSegments, + )..where((t) => t.planId.equals(plan.id))).get(); + final existingTankIds = existingTankRows.map((r) => r.id).toSet(); + final existingSegmentIds = existingSegmentRows.map((r) => r.id).toSet(); + final tankCreatedAt = { + for (final r in existingTankRows) r.id: r.createdAt, + }; + final segmentCreatedAt = { + for (final r in existingSegmentRows) r.id: r.createdAt, + }; + + await _db + .into(_db.divePlans) + .insertOnConflictUpdate(_planCompanion(plan, now, summary)); + + for (var i = 0; i < plan.tanks.length; i++) { + await _db + .into(_db.divePlanTanks) + .insertOnConflictUpdate( + _tankCompanion( + plan.tanks[i], + plan.id, + i, + now, + createdAt: tankCreatedAt[plan.tanks[i].id], + ), + ); + } + for (var i = 0; i < plan.segments.length; i++) { + await _db + .into(_db.divePlanSegments) + .insertOnConflictUpdate( + _segmentCompanion( + plan.segments[i], + plan.id, + i, + now, + createdAt: segmentCreatedAt[plan.segments[i].id], + ), + ); + } + + final keptTankIds = plan.tanks.map((t) => t.id).toSet(); + final keptSegmentIds = plan.segments.map((s) => s.id).toSet(); + removedSegmentIds.addAll(existingSegmentIds.difference(keptSegmentIds)); + removedTankIds.addAll(existingTankIds.difference(keptTankIds)); + + // Segments before tanks: segments FK-reference tanks. + for (final id in removedSegmentIds) { + await (_db.delete( + _db.divePlanSegments, + )..where((t) => t.id.equals(id))).go(); + } + for (final id in removedTankIds) { + await (_db.delete( + _db.divePlanTanks, + )..where((t) => t.id.equals(id))).go(); + } + }); + + // Sync bookkeeping AFTER the transaction commits so a rollback leaves + // no stray pending markers or tombstones. + await _syncRepository.markRecordPending( + entityType: 'divePlans', + recordId: plan.id, + localUpdatedAt: now, + ); + for (final tank in plan.tanks) { + await _syncRepository.markRecordPending( + entityType: 'divePlanTanks', + recordId: tank.id, + localUpdatedAt: now, + ); + } + for (final segment in plan.segments) { + await _syncRepository.markRecordPending( + entityType: 'divePlanSegments', + recordId: segment.id, + localUpdatedAt: now, + ); + } + for (final id in removedSegmentIds) { + await _syncRepository.logDeletion( + entityType: 'divePlanSegments', + recordId: id, + ); + } + for (final id in removedTankIds) { + await _syncRepository.logDeletion( + entityType: 'divePlanTanks', + recordId: id, + ); + } + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to save plan ${plan.id}', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + Future getPlan(String id) async { + try { + final row = await (_db.select( + _db.divePlans, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + if (row == null) return null; + + final tankRows = + await (_db.select(_db.divePlanTanks) + ..where((t) => t.planId.equals(id)) + ..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])) + .get(); + final segmentRows = + await (_db.select(_db.divePlanSegments) + ..where((t) => t.planId.equals(id)) + ..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])) + .get(); + + return _mapPlan(row, tankRows, segmentRows); + } catch (e, stackTrace) { + _log.error('Failed to load plan $id', error: e, stackTrace: stackTrace); + rethrow; + } + } + + Future> getAllPlanSummaries() async { + final rows = await (_db.select( + _db.divePlans, + )..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])).get(); + return rows + .map( + (r) => domain.DivePlanSummary( + id: r.id, + name: r.name, + updatedAt: DateTime.fromMillisecondsSinceEpoch(r.updatedAt), + maxDepth: r.summaryMaxDepth, + runtimeSeconds: r.summaryRuntimeSeconds, + ttsSeconds: r.summaryTtsSeconds, + mode: domain.PlanMode.values.byName(r.mode), + ), + ) + .toList(); + } + + Future deletePlan(String id) async { + try { + final tankIds = (await (_db.select( + _db.divePlanTanks, + )..where((t) => t.planId.equals(id))).get()).map((r) => r.id).toList(); + final segmentIds = (await (_db.select( + _db.divePlanSegments, + )..where((t) => t.planId.equals(id))).get()).map((r) => r.id).toList(); + + await _db.transaction(() async { + await (_db.delete( + _db.divePlanSegments, + )..where((t) => t.planId.equals(id))).go(); + await (_db.delete( + _db.divePlanTanks, + )..where((t) => t.planId.equals(id))).go(); + await (_db.delete(_db.divePlans)..where((t) => t.id.equals(id))).go(); + }); + + for (final segmentId in segmentIds) { + await _syncRepository.logDeletion( + entityType: 'divePlanSegments', + recordId: segmentId, + ); + } + for (final tankId in tankIds) { + await _syncRepository.logDeletion( + entityType: 'divePlanTanks', + recordId: tankId, + ); + } + await _syncRepository.logDeletion(entityType: 'divePlans', recordId: id); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error('Failed to delete plan $id', error: e, stackTrace: stackTrace); + rethrow; + } + } + + /// Copies a plan (new ids everywhere, segments' tank references remapped to + /// the new tank ids, name suffixed). Returns the new plan, or null when the + /// source does not exist. + Future duplicatePlan(String id) async { + final source = await getPlan(id); + if (source == null) return null; + + final now = DateTime.now(); + final tankIdMap = {for (final t in source.tanks) t.id: _uuid.v4()}; + final newTanks = source.tanks + .map((t) => t.copyWith(id: tankIdMap[t.id])) + .toList(); + final newSegments = source.segments + .map( + (s) => s.copyWith( + id: _uuid.v4(), + tankId: tankIdMap[s.tankId] ?? s.tankId, + switchToTankId: s.switchToTankId != null + ? tankIdMap[s.switchToTankId] + : null, + clearSwitchToTankId: s.switchToTankId == null, + ), + ) + .toList(); + + final copy = source.copyWith( + id: _uuid.v4(), + name: '${source.name} (copy)', + createdAt: now, + updatedAt: now, + tanks: newTanks, + segments: newSegments, + clearLinkedDiveId: true, + ); + final sourceRow = await (_db.select( + _db.divePlans, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + await savePlan( + copy, + summary: sourceRow?.summaryMaxDepth != null + ? PlanSummaryData( + maxDepth: sourceRow!.summaryMaxDepth!, + runtimeSeconds: sourceRow.summaryRuntimeSeconds ?? 0, + ttsSeconds: sourceRow.summaryTtsSeconds, + ) + : null, + ); + return copy; + } + + // ---- mapping ---- + + db.DivePlansCompanion _planCompanion( + domain.DivePlan plan, + int now, + PlanSummaryData? summary, + ) { + return db.DivePlansCompanion( + id: Value(plan.id), + name: Value(plan.name), + notes: Value(plan.notes), + mode: Value(plan.mode.name), + siteId: Value(plan.siteId), + sourceDiveId: Value(plan.sourceDiveId), + linkedDiveId: Value(plan.linkedDiveId), + altitude: Value(plan.altitude), + waterType: Value(plan.waterType?.name), + gfLow: Value(plan.gfLow), + gfHigh: Value(plan.gfHigh), + descentRate: Value(plan.descentRate), + ascentRate: Value(plan.ascentRate), + lastStopDepth: Value(plan.lastStopDepth), + gasSwitchStopSeconds: Value(plan.gasSwitchStopSeconds), + airBreakO2Seconds: Value(plan.airBreaks?.o2Seconds), + airBreakBreakSeconds: Value(plan.airBreaks?.breakSeconds), + sacBottom: Value(plan.sacBottom), + sacDeco: Value(plan.sacDeco), + sacStressed: Value(plan.sacStressed), + reservePressure: Value(plan.reservePressure), + surfaceIntervalSeconds: Value(plan.surfaceInterval?.inSeconds), + setpointLow: Value(plan.setpointLow), + setpointHigh: Value(plan.setpointHigh), + setpointSwitchDepth: Value(plan.setpointSwitchDepth), + deviationDepthDelta: Value(plan.deviationDepthDelta), + deviationTimeMinutes: Value(plan.deviationTimeMinutes), + turnPressureRule: Value(plan.turnPressureRule?.name), + turnPressureFraction: Value(plan.turnPressureFraction), + summaryMaxDepth: summary != null + ? Value(summary.maxDepth) + : const Value.absent(), + summaryRuntimeSeconds: summary != null + ? Value(summary.runtimeSeconds) + : const Value.absent(), + summaryTtsSeconds: summary != null + ? Value(summary.ttsSeconds) + : const Value.absent(), + createdAt: Value(plan.createdAt.millisecondsSinceEpoch), + updatedAt: Value(now), + ); + } + + db.DivePlanTanksCompanion _tankCompanion( + DiveTank tank, + String planId, + int sortOrder, + int now, { + int? createdAt, + }) { + return db.DivePlanTanksCompanion( + id: Value(tank.id), + planId: Value(planId), + name: Value(tank.name), + volume: Value(tank.volume), + workingPressure: Value(tank.workingPressure), + startPressure: Value(tank.startPressure), + gasO2: Value(tank.gasMix.o2), + gasHe: Value(tank.gasMix.he), + role: Value(tank.role.name), + material: Value(tank.material?.name), + presetName: Value(tank.presetName), + sortOrder: Value(sortOrder), + createdAt: Value(createdAt ?? now), + updatedAt: Value(now), + ); + } + + db.DivePlanSegmentsCompanion _segmentCompanion( + PlanSegment segment, + String planId, + int sortOrder, + int now, { + int? createdAt, + }) { + return db.DivePlanSegmentsCompanion( + id: Value(segment.id), + planId: Value(planId), + type: Value(segment.type.name), + startDepth: Value(segment.startDepth), + endDepth: Value(segment.endDepth), + durationSeconds: Value(segment.durationSeconds), + tankId: Value(segment.tankId), + gasO2: Value(segment.gasMix.o2), + gasHe: Value(segment.gasMix.he), + rate: Value(segment.rate), + switchToTankId: Value(segment.switchToTankId), + sortOrder: Value(sortOrder), + createdAt: Value(createdAt ?? now), + updatedAt: Value(now), + ); + } + + domain.DivePlan _mapPlan( + db.DivePlan row, + List tankRows, + List segmentRows, + ) { + return domain.DivePlan( + id: row.id, + name: row.name, + notes: row.notes, + siteId: row.siteId, + createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), + updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt), + mode: domain.PlanMode.values.byName(row.mode), + altitude: row.altitude, + waterType: row.waterType != null + ? WaterType.values.byName(row.waterType!) + : null, + gfLow: row.gfLow, + gfHigh: row.gfHigh, + descentRate: row.descentRate, + ascentRate: row.ascentRate, + lastStopDepth: row.lastStopDepth, + gasSwitchStopSeconds: row.gasSwitchStopSeconds, + airBreaks: + row.airBreakO2Seconds != null && row.airBreakBreakSeconds != null + ? AirBreakPolicy( + o2Seconds: row.airBreakO2Seconds!, + breakSeconds: row.airBreakBreakSeconds!, + ) + : null, + sacBottom: row.sacBottom, + sacDeco: row.sacDeco, + sacStressed: row.sacStressed, + reservePressure: row.reservePressure, + surfaceInterval: row.surfaceIntervalSeconds != null + ? Duration(seconds: row.surfaceIntervalSeconds!) + : null, + sourceDiveId: row.sourceDiveId, + linkedDiveId: row.linkedDiveId, + setpointLow: row.setpointLow, + setpointHigh: row.setpointHigh, + setpointSwitchDepth: row.setpointSwitchDepth, + deviationDepthDelta: row.deviationDepthDelta, + deviationTimeMinutes: row.deviationTimeMinutes, + turnPressureRule: row.turnPressureRule != null + ? domain.TurnPressureRule.values.byName(row.turnPressureRule!) + : null, + turnPressureFraction: row.turnPressureFraction, + tanks: tankRows + .map( + (t) => DiveTank( + id: t.id, + name: t.name, + volume: t.volume, + workingPressure: t.workingPressure, + startPressure: t.startPressure, + gasMix: GasMix(o2: t.gasO2, he: t.gasHe), + role: TankRole.values.byName(t.role), + material: t.material != null + ? TankMaterial.values.byName(t.material!) + : null, + order: t.sortOrder, + presetName: t.presetName, + ), + ) + .toList(), + segments: segmentRows + .map( + (s) => PlanSegment( + id: s.id, + type: SegmentType.values.byName(s.type), + startDepth: s.startDepth, + endDepth: s.endDepth, + durationSeconds: s.durationSeconds, + tankId: s.tankId, + gasMix: GasMix(o2: s.gasO2, he: s.gasHe), + rate: s.rate, + switchToTankId: s.switchToTankId, + order: s.sortOrder, + ), + ) + .toList(), + ); + } +} diff --git a/lib/features/planner/domain/entities/dive_plan.dart b/lib/features/planner/domain/entities/dive_plan.dart new file mode 100644 index 000000000..e937879e3 --- /dev/null +++ b/lib/features/planner/domain/entities/dive_plan.dart @@ -0,0 +1,286 @@ +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; + +/// Breathing mode of a saved dive plan. +enum PlanMode { oc, ccr } + +/// Gas turn-pressure rule for penetration planning (Phase 5). +enum TurnPressureRule { allUsable, halves, thirds, custom } + +/// The persisted dive plan aggregate (planner redesign Phase 2). +/// +/// Inputs only — schedules, consumption, and issues are always recomputed by +/// the PlanEngine. Segments describe the user-authored bottom portion of the +/// dive; ascent and deco are computed. +class DivePlan extends Equatable { + // Identity / meta + final String id; + final String name; + final String notes; + final String? siteId; + final DateTime createdAt; + final DateTime updatedAt; + + // Mode + environment + final PlanMode mode; + final double? altitude; + final WaterType? waterType; + + // Deco settings + final int gfLow; + final int gfHigh; + final double descentRate; + final double ascentRate; + final double lastStopDepth; + final int gasSwitchStopSeconds; + final AirBreakPolicy? airBreaks; + + // Gas planning + final double sacBottom; + final double? sacDeco; + final double? sacStressed; + final double reservePressure; + + // Repetitive context + final Duration? surfaceInterval; + final String? sourceDiveId; + final String? linkedDiveId; + + // CCR config (Phase 4 UI; persisted now) + final double? setpointLow; + final double? setpointHigh; + final double? setpointSwitchDepth; + + // Contingency config (Phase 5 UI; persisted now) + final double deviationDepthDelta; + final int deviationTimeMinutes; + final TurnPressureRule? turnPressureRule; + final double? turnPressureFraction; + + // Content + final List segments; + final List tanks; + + const DivePlan({ + required this.id, + required this.name, + this.notes = '', + this.siteId, + required this.createdAt, + required this.updatedAt, + this.mode = PlanMode.oc, + this.altitude, + this.waterType, + required this.gfLow, + required this.gfHigh, + this.descentRate = 18.0, + this.ascentRate = 9.0, + this.lastStopDepth = 3.0, + this.gasSwitchStopSeconds = 0, + this.airBreaks, + this.sacBottom = 15.0, + this.sacDeco, + this.sacStressed, + this.reservePressure = 50.0, + this.surfaceInterval, + this.sourceDiveId, + this.linkedDiveId, + this.setpointLow, + this.setpointHigh, + this.setpointSwitchDepth, + this.deviationDepthDelta = 5.0, + this.deviationTimeMinutes = 5, + this.turnPressureRule, + this.turnPressureFraction, + this.segments = const [], + this.tanks = const [], + }); + + /// Deco SAC: explicit value or the 0.8x-of-bottom default. + double get sacDecoEffective => sacDeco ?? sacBottom * 0.8; + + /// Stressed (bailout/rock-bottom) SAC: explicit or 2.5x bottom. + double get sacStressedEffective => sacStressed ?? sacBottom * 2.5; + + /// Deepest point across the user-authored segments (0 if none). + double get maxDepth { + double deepest = 0; + for (final segment in segments) { + if (segment.startDepth > deepest) deepest = segment.startDepth; + if (segment.endDepth > deepest) deepest = segment.endDepth; + } + return deepest; + } + + DivePlan copyWith({ + String? id, + String? name, + String? notes, + String? siteId, + bool clearSiteId = false, + DateTime? createdAt, + DateTime? updatedAt, + PlanMode? mode, + double? altitude, + bool clearAltitude = false, + WaterType? waterType, + bool clearWaterType = false, + int? gfLow, + int? gfHigh, + double? descentRate, + double? ascentRate, + double? lastStopDepth, + int? gasSwitchStopSeconds, + AirBreakPolicy? airBreaks, + bool clearAirBreaks = false, + double? sacBottom, + double? sacDeco, + bool clearSacDeco = false, + double? sacStressed, + bool clearSacStressed = false, + double? reservePressure, + Duration? surfaceInterval, + bool clearSurfaceInterval = false, + String? sourceDiveId, + bool clearSourceDiveId = false, + String? linkedDiveId, + bool clearLinkedDiveId = false, + double? setpointLow, + bool clearSetpointLow = false, + double? setpointHigh, + bool clearSetpointHigh = false, + double? setpointSwitchDepth, + bool clearSetpointSwitchDepth = false, + double? deviationDepthDelta, + int? deviationTimeMinutes, + TurnPressureRule? turnPressureRule, + bool clearTurnPressureRule = false, + double? turnPressureFraction, + bool clearTurnPressureFraction = false, + List? segments, + List? tanks, + }) { + return DivePlan( + id: id ?? this.id, + name: name ?? this.name, + notes: notes ?? this.notes, + siteId: clearSiteId ? null : (siteId ?? this.siteId), + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + mode: mode ?? this.mode, + altitude: clearAltitude ? null : (altitude ?? this.altitude), + waterType: clearWaterType ? null : (waterType ?? this.waterType), + gfLow: gfLow ?? this.gfLow, + gfHigh: gfHigh ?? this.gfHigh, + descentRate: descentRate ?? this.descentRate, + ascentRate: ascentRate ?? this.ascentRate, + lastStopDepth: lastStopDepth ?? this.lastStopDepth, + gasSwitchStopSeconds: gasSwitchStopSeconds ?? this.gasSwitchStopSeconds, + airBreaks: clearAirBreaks ? null : (airBreaks ?? this.airBreaks), + sacBottom: sacBottom ?? this.sacBottom, + sacDeco: clearSacDeco ? null : (sacDeco ?? this.sacDeco), + sacStressed: clearSacStressed ? null : (sacStressed ?? this.sacStressed), + reservePressure: reservePressure ?? this.reservePressure, + surfaceInterval: clearSurfaceInterval + ? null + : (surfaceInterval ?? this.surfaceInterval), + sourceDiveId: clearSourceDiveId + ? null + : (sourceDiveId ?? this.sourceDiveId), + linkedDiveId: clearLinkedDiveId + ? null + : (linkedDiveId ?? this.linkedDiveId), + setpointLow: clearSetpointLow ? null : (setpointLow ?? this.setpointLow), + setpointHigh: clearSetpointHigh + ? null + : (setpointHigh ?? this.setpointHigh), + setpointSwitchDepth: clearSetpointSwitchDepth + ? null + : (setpointSwitchDepth ?? this.setpointSwitchDepth), + deviationDepthDelta: deviationDepthDelta ?? this.deviationDepthDelta, + deviationTimeMinutes: deviationTimeMinutes ?? this.deviationTimeMinutes, + turnPressureRule: clearTurnPressureRule + ? null + : (turnPressureRule ?? this.turnPressureRule), + turnPressureFraction: clearTurnPressureFraction + ? null + : (turnPressureFraction ?? this.turnPressureFraction), + segments: segments ?? this.segments, + tanks: tanks ?? this.tanks, + ); + } + + @override + List get props => [ + id, + name, + notes, + siteId, + createdAt, + updatedAt, + mode, + altitude, + waterType, + gfLow, + gfHigh, + descentRate, + ascentRate, + lastStopDepth, + gasSwitchStopSeconds, + airBreaks?.o2Seconds, + airBreaks?.breakSeconds, + sacBottom, + sacDeco, + sacStressed, + reservePressure, + surfaceInterval, + sourceDiveId, + linkedDiveId, + setpointLow, + setpointHigh, + setpointSwitchDepth, + deviationDepthDelta, + deviationTimeMinutes, + turnPressureRule, + turnPressureFraction, + segments, + tanks, + ]; +} + +/// Lightweight row for the saved-plans list (denormalized summary columns — +/// no engine run per row). +class DivePlanSummary extends Equatable { + final String id; + final String name; + final DateTime updatedAt; + final double? maxDepth; + final int? runtimeSeconds; + final int? ttsSeconds; + final PlanMode mode; + + const DivePlanSummary({ + required this.id, + required this.name, + required this.updatedAt, + this.maxDepth, + this.runtimeSeconds, + this.ttsSeconds, + this.mode = PlanMode.oc, + }); + + @override + List get props => [ + id, + name, + updatedAt, + maxDepth, + runtimeSeconds, + ttsSeconds, + mode, + ]; +} diff --git a/lib/features/planner/domain/entities/plan_outcome.dart b/lib/features/planner/domain/entities/plan_outcome.dart new file mode 100644 index 000000000..40eb5139e --- /dev/null +++ b/lib/features/planner/domain/entities/plan_outcome.dart @@ -0,0 +1,202 @@ +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/deco/deco_model.dart'; + +/// Severity of a computed plan issue, ordered least to most severe. +enum PlanIssueSeverity { info, warning, alert, critical } + +/// What went wrong (or deserves attention) in a computed plan. +enum PlanIssueType { + ppO2High, + ppO2Critical, + hypoxicGas, + endExceeded, + gasDensityHigh, + gasDensityCritical, + cnsWarning, + cnsCritical, + otuHigh, + gasReserveViolation, + gasOut, + ndlExceededNoDecoGas, +} + +/// One issue found while computing a plan. +class PlanIssue extends Equatable { + final PlanIssueType type; + final PlanIssueSeverity severity; + final String message; + final int? atRuntime; + final double? atDepth; + final String? segmentId; + final double? value; + final double? threshold; + + const PlanIssue({ + required this.type, + required this.severity, + required this.message, + this.atRuntime, + this.atDepth, + this.segmentId, + this.value, + this.threshold, + }); + + @override + List get props => [ + type, + severity, + message, + atRuntime, + atDepth, + segmentId, + value, + threshold, + ]; +} + +/// A computed decompression stop with its gas and arrival time. +class PlanStop extends Equatable { + final double depthMeters; + final int durationSeconds; + final int airBreakSeconds; + final double gasFO2; + final double gasFHe; + final String? tankId; + final int arrivalRuntimeSeconds; + + const PlanStop({ + required this.depthMeters, + required this.durationSeconds, + this.airBreakSeconds = 0, + required this.gasFO2, + required this.gasFHe, + this.tankId, + required this.arrivalRuntimeSeconds, + }); + + @override + List get props => [ + depthMeters, + durationSeconds, + airBreakSeconds, + gasFO2, + gasFHe, + tankId, + arrivalRuntimeSeconds, + ]; +} + +/// Deco/exposure state at the end of one user-authored segment. +class SegmentOutcome extends Equatable { + final String segmentId; + final int startRuntime; + final int endRuntime; + final int ndlAtEnd; + final double ceilingAtEnd; + final int ttsAtEnd; + final double cns; + final double otu; + final double maxPpO2; + + const SegmentOutcome({ + required this.segmentId, + required this.startRuntime, + required this.endRuntime, + required this.ndlAtEnd, + required this.ceilingAtEnd, + required this.ttsAtEnd, + required this.cns, + required this.otu, + required this.maxPpO2, + }); + + bool get inDeco => ndlAtEnd < 0; + + @override + List get props => [ + segmentId, + startRuntime, + endRuntime, + ndlAtEnd, + ceilingAtEnd, + ttsAtEnd, + cns, + otu, + maxPpO2, + ]; +} + +/// Per-tank consumption result. +class PlanTankUsage extends Equatable { + final String tankId; + final double litersUsed; + final double? remainingPressure; + final double percentUsed; + final bool reserveViolation; + + const PlanTankUsage({ + required this.tankId, + required this.litersUsed, + this.remainingPressure, + required this.percentUsed, + this.reserveViolation = false, + }); + + @override + List get props => [ + tankId, + litersUsed, + remainingPressure, + percentUsed, + reserveViolation, + ]; +} + +/// Everything the PlanEngine computes from a DivePlan. +class PlanOutcome { + /// Total runtime: user segments + travel + stops. + final int runtimeSeconds; + final double maxDepth; + + /// NDL (seconds, -1 = in deco) and TTS at the bottom reference point. + final int ndlAtBottom; + final int ttsAtBottom; + + final List stops; + final List segmentOutcomes; + final List tankUsages; + final double cnsEnd; + final double otuTotal; + + /// Severity-sorted, most severe first. + final List issues; + + /// Tissue state at the end of the user-authored segments (before the + /// computed ascent), plus the per-segment timeline for scrubbing. + final BuhlmannState endTissue; + final List<(int runtimeSeconds, BuhlmannState state)> tissueTimeline; + + const PlanOutcome({ + required this.runtimeSeconds, + required this.maxDepth, + required this.ndlAtBottom, + required this.ttsAtBottom, + required this.stops, + required this.segmentOutcomes, + required this.tankUsages, + required this.cnsEnd, + required this.otuTotal, + required this.issues, + required this.endTissue, + required this.tissueTimeline, + }); + + /// No critical issue present. + bool get isDiveable => + !issues.any((i) => i.severity == PlanIssueSeverity.critical); + + int get totalDecoSeconds => + stops.fold(0, (sum, s) => sum + s.durationSeconds); +} diff --git a/lib/features/planner/domain/services/dive_plan_state_mapper.dart b/lib/features/planner/domain/services/dive_plan_state_mapper.dart new file mode 100644 index 000000000..5ec5a0b83 --- /dev/null +++ b/lib/features/planner/domain/services/dive_plan_state_mapper.dart @@ -0,0 +1,66 @@ +import 'package:submersion/features/dive_planner/domain/entities/plan_result.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; + +/// Maps between the legacy planner UI state ([DivePlanState]) and the +/// persisted [domain.DivePlan] aggregate. +/// +/// The legacy state carries a subset of the aggregate; [existing] preserves +/// fields the state does not know about (mode, rates, CCR/contingency +/// config, water type, dive links) across an edit-save cycle so a plan +/// touched by the old UI does not lose them. +domain.DivePlan divePlanFromState( + DivePlanState state, { + domain.DivePlan? existing, +}) { + final base = + existing ?? + domain.DivePlan( + id: state.id, + name: state.name, + gfLow: state.gfLow, + gfHigh: state.gfHigh, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + ); + return base.copyWith( + id: state.id, + name: state.name, + notes: state.notes, + siteId: state.siteId, + clearSiteId: state.siteId == null, + altitude: state.altitude, + clearAltitude: state.altitude == null, + gfLow: state.gfLow, + gfHigh: state.gfHigh, + sacBottom: state.sacRate, + reservePressure: state.reservePressure, + surfaceInterval: state.surfaceInterval, + clearSurfaceInterval: state.surfaceInterval == null, + segments: state.segments, + tanks: state.tanks, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + ); +} + +/// Restores the legacy planner state from a persisted plan. +DivePlanState stateFromDivePlan(domain.DivePlan plan) { + return DivePlanState( + id: plan.id, + name: plan.name, + notes: plan.notes, + siteId: plan.siteId, + altitude: plan.altitude, + gfLow: plan.gfLow, + gfHigh: plan.gfHigh, + sacRate: plan.sacBottom, + reservePressure: plan.reservePressure, + surfaceInterval: plan.surfaceInterval, + segments: plan.segments, + tanks: plan.tanks, + isDirty: false, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, + ); +} diff --git a/lib/features/planner/domain/services/plan_engine.dart b/lib/features/planner/domain/services/plan_engine.dart new file mode 100644 index 000000000..6f51d8c72 --- /dev/null +++ b/lib/features/planner/domain/services/plan_engine.dart @@ -0,0 +1,619 @@ +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/ascent/ascent_gas_plan.dart'; +import 'package:submersion/core/deco/deco_model.dart'; +import 'package:submersion/core/deco/entities/breathing_config.dart'; +import 'package:submersion/core/deco/entities/dive_environment.dart'; +import 'package:submersion/core/deco/gas_density.dart'; +import 'package:submersion/core/deco/o2_toxicity_calculator.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/core/utils/gas_compressibility.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; + +/// Thresholds and policy limits the engine evaluates plans against. +class PlanEngineConfig { + final double ppO2Working; + final double ppO2Deco; + final int cnsWarningThreshold; + final bool o2Narcotic; + final double endLimitMeters; + final double otuLimit; + + const PlanEngineConfig({ + this.ppO2Working = 1.4, + this.ppO2Deco = 1.6, + this.cnsWarningThreshold = 80, + this.o2Narcotic = true, + this.endLimitMeters = 30.0, + this.otuLimit = 300.0, + }); +} + +/// Turns a [domain.DivePlan] into a [PlanOutcome] on the Phase 1 engine +/// seams: DecoModel (BuhlmannGf), SchedulePolicy, BreathingConfig, +/// DiveEnvironment. +/// +/// Phase 2 computes open-circuit plans; a `mode == ccr` plan is computed on +/// its OC gases and flagged (CCR schedules arrive in Phase 4). +class PlanEngine { + final PlanEngineConfig config; + + const PlanEngine({this.config = const PlanEngineConfig()}); + + PlanOutcome compute(domain.DivePlan plan, {TissueState? startState}) { + // The engine hard-codes BuhlmannGf and casts state back to BuhlmannState; + // reject a foreign seed up front so misuse fails predictably instead of + // crashing mid-computation on an opaque cast. + if (startState != null && startState is! BuhlmannState) { + throw ArgumentError.value( + startState, + 'startState', + 'PlanEngine only accepts a BuhlmannState tissue seed', + ); + } + final environment = DiveEnvironment.forConditions( + altitudeMeters: plan.altitude, + waterType: plan.waterType, + ); + final policy = SchedulePolicy( + lastStopDepth: plan.lastStopDepth, + ascentRate: plan.ascentRate, + gasSwitchStopSeconds: plan.gasSwitchStopSeconds, + airBreaks: plan.airBreaks, + ); + final model = BuhlmannGf( + gfLow: plan.gfLow / 100.0, + gfHigh: plan.gfHigh / 100.0, + environment: environment, + policy: policy, + ); + final o2Calc = O2ToxicityCalculator( + ppO2WarningThreshold: config.ppO2Working, + ppO2CriticalThreshold: config.ppO2Deco, + cnsWarningThreshold: config.cnsWarningThreshold, + ); + + final segments = List.from(plan.segments) + ..sort((a, b) => a.order.compareTo(b.order)); + final ascentPlan = _ascentPlanFor(plan.tanks); + + var state = startState ?? model.initial(); + var runtime = 0; + var cns = 0.0; + var otu = 0.0; + var maxPpO2 = 0.0; + int? ndlAtBottom; + int? ttsAtBottom; + final maxDepth = plan.maxDepth; + final segmentOutcomes = []; + final timeline = <(int, BuhlmannState)>[]; + + for (final segment in segments) { + final startRuntime = runtime; + final breathing = OpenCircuit( + fO2: segment.gasMix.o2 / 100.0, + fHe: segment.gasMix.he / 100.0, + ); + + state = model.applySegment( + state, + DecoSegment( + startDepth: segment.startDepth, + endDepth: segment.endDepth, + durationSeconds: segment.durationSeconds, + ), + breathing, + ); + runtime += segment.durationSeconds; + timeline.add((runtime, state as BuhlmannState)); + + final deeperEnd = segment.startDepth > segment.endDepth + ? segment.startDepth + : segment.endDepth; + final segmentMaxPpO2 = O2ToxicityCalculator.calculatePpO2( + deeperEnd, + segment.gasMix.o2 / 100.0, + ); + if (segmentMaxPpO2 > maxPpO2) maxPpO2 = segmentMaxPpO2; + + final avgPpO2 = O2ToxicityCalculator.calculatePpO2( + segment.avgDepth, + segment.gasMix.o2 / 100.0, + ); + cns += o2Calc.calculateCnsForSegment(avgPpO2, segment.durationSeconds); + otu += o2Calc.calculateOtuForSegment(avgPpO2, segment.durationSeconds); + + final ndl = model.ndlSeconds( + state, + depthMeters: segment.endDepth, + breathing: breathing, + ); + final ceiling = model.ceilingMeters( + state, + currentDepth: segment.endDepth, + ); + final tts = segment.endDepth > 0 + ? model + .schedule( + state, + currentDepth: segment.endDepth, + gases: ascentPlan, + ) + .ttsSeconds + : 0; + + if (segment.type == SegmentType.bottom || + segment.endDepth >= maxDepth - 0.1) { + ndlAtBottom = ndl; + ttsAtBottom = tts; + } + + segmentOutcomes.add( + SegmentOutcome( + segmentId: segment.id, + startRuntime: startRuntime, + endRuntime: runtime, + ndlAtEnd: ndl, + ceilingAtEnd: ceiling, + ttsAtEnd: tts, + cns: cns, + otu: otu, + maxPpO2: segmentMaxPpO2, + ), + ); + } + + // Computed ascent from the last user depth. + final lastDepth = segments.isEmpty ? 0.0 : segments.last.endDepth; + final schedule = lastDepth > 0 + ? model.schedule(state, currentDepth: lastDepth, gases: ascentPlan) + : const DecoSchedule(stops: [], ttsSeconds: 0); + final stops = _mapStops(schedule, plan, ascentPlan, lastDepth, runtime); + + // Decompression stops carry O2 exposure too — a long stop on a rich deco + // gas is where CNS/OTU pile up — so accumulate them before issues and the + // outcome are built. Each stop splits into its primary gas and, when air + // breaks apply, the break gas for that portion. + for (final stop in stops) { + final stopPressure = environment.pressureAtDepth(stop.depthMeters); + final primarySeconds = stop.durationSeconds - stop.airBreakSeconds; + final primaryPpO2 = stopPressure * stop.gasFO2; + cns += o2Calc.calculateCnsForSegment(primaryPpO2, primarySeconds); + otu += o2Calc.calculateOtuForSegment(primaryPpO2, primarySeconds); + if (stop.airBreakSeconds > 0) { + final breakGas = ascentPlan.breakGasForDepth(stop.depthMeters); + final breakFO2 = breakGas != null + ? 1.0 - breakGas.fN2 - breakGas.fHe + : stop.gasFO2; + final breakPpO2 = stopPressure * breakFO2; + cns += o2Calc.calculateCnsForSegment(breakPpO2, stop.airBreakSeconds); + otu += o2Calc.calculateOtuForSegment(breakPpO2, stop.airBreakSeconds); + } + } + + final tankUsages = _computeTankUsages( + plan, + segments, + stops, + lastDepth, + environment, + ascentPlan, + ); + final issues = _computeIssues( + plan, + segments, + segmentOutcomes, + tankUsages, + cns, + otu, + environment, + ); + + return PlanOutcome( + runtimeSeconds: runtime + schedule.ttsSeconds, + maxDepth: maxDepth, + ndlAtBottom: ndlAtBottom ?? 0, + ttsAtBottom: ttsAtBottom ?? schedule.ttsSeconds, + stops: stops, + segmentOutcomes: segmentOutcomes, + tankUsages: tankUsages, + cnsEnd: cns, + otuTotal: otu, + issues: issues, + endTissue: state as BuhlmannState, + tissueTimeline: timeline, + ); + } + + /// SAC-based consumption: bottom SAC on descent/bottom segments, deco SAC + /// on everything shallower-bound (ascent legs, stops), depth pressure via + /// the plan's environment, remaining pressure compressibility-corrected. + List _computeTankUsages( + domain.DivePlan plan, + List segments, + List stops, + double lastDepth, + DiveEnvironment environment, + AscentGasPlan ascentPlan, + ) { + if (plan.tanks.isEmpty) return const []; + final liters = {for (final t in plan.tanks) t.id: 0.0}; + final fallbackTankId = plan.tanks.first.id; + + void charge(String? tankId, double amount) { + final id = tankId != null && liters.containsKey(tankId) + ? tankId + : fallbackTankId; + liters[id] = (liters[id] ?? 0) + amount; + } + + for (final segment in segments) { + final sac = + segment.type == SegmentType.bottom || + segment.type == SegmentType.descent + ? plan.sacBottom + : plan.sacDecoEffective; + charge( + segment.tankId, + sac * + (segment.durationSeconds / 60.0) * + environment.pressureAtDepth(segment.avgDepth), + ); + } + + // Computed ascent: travel legs and stops on the deco SAC. + var depth = lastDepth; + for (final stop in stops) { + final legSeconds = ((depth - stop.depthMeters) / plan.ascentRate * 60) + .round(); + final legAvg = (depth + stop.depthMeters) / 2.0; + charge( + stop.tankId, + plan.sacDecoEffective * + (legSeconds / 60.0) * + environment.pressureAtDepth(legAvg), + ); + charge( + stop.tankId, + plan.sacDecoEffective * + (stop.durationSeconds / 60.0) * + environment.pressureAtDepth(stop.depthMeters), + ); + depth = stop.depthMeters; + } + if (depth > 0) { + final legSeconds = (depth / plan.ascentRate * 60).round(); + final gas = ascentPlan.gasForDepth(depth); + charge( + _tankForGas(plan.tanks, 1.0 - gas.fN2 - gas.fHe, gas.fHe), + plan.sacDecoEffective * + (legSeconds / 60.0) * + environment.pressureAtDepth(depth / 2.0), + ); + } + + return [ + for (final tank in plan.tanks) + () { + final used = liters[tank.id] ?? 0.0; + final start = tank.startPressure; + final remaining = start != null + ? pressureAfterConsuming( + tankSizeLiters: tank.volume ?? 11.0, + startPressureBar: start, + litersConsumed: used, + o2Percent: tank.gasMix.o2, + hePercent: tank.gasMix.he, + ) + : null; + return PlanTankUsage( + tankId: tank.id, + litersUsed: used, + remainingPressure: remaining, + percentUsed: start != null && start > 0 + ? (start - (remaining ?? 0)) / start * 100.0 + : 0.0, + reserveViolation: + remaining != null && remaining < plan.reservePressure, + ); + }(), + ]; + } + + List _computeIssues( + domain.DivePlan plan, + List segments, + List segmentOutcomes, + List tankUsages, + double cns, + double otu, + DiveEnvironment environment, + ) { + final issues = []; + + for (var i = 0; i < segments.length; i++) { + final segment = segments[i]; + final outcome = segmentOutcomes[i]; + final deeperEnd = segment.startDepth > segment.endDepth + ? segment.startDepth + : segment.endDepth; + final shallowerEnd = segment.startDepth < segment.endDepth + ? segment.startDepth + : segment.endDepth; + final fO2 = segment.gasMix.o2 / 100.0; + final fHe = segment.gasMix.he / 100.0; + + if (outcome.maxPpO2 > config.ppO2Deco) { + issues.add( + PlanIssue( + type: PlanIssueType.ppO2Critical, + severity: PlanIssueSeverity.critical, + message: + 'ppO2 ${outcome.maxPpO2.toStringAsFixed(2)} bar exceeds the ' + 'deco limit', + atDepth: deeperEnd, + segmentId: segment.id, + value: outcome.maxPpO2, + threshold: config.ppO2Deco, + ), + ); + } else if (outcome.maxPpO2 > config.ppO2Working) { + issues.add( + PlanIssue( + type: PlanIssueType.ppO2High, + severity: PlanIssueSeverity.warning, + message: + 'ppO2 ${outcome.maxPpO2.toStringAsFixed(2)} bar exceeds the ' + 'working limit', + atDepth: deeperEnd, + segmentId: segment.id, + value: outcome.maxPpO2, + threshold: config.ppO2Working, + ), + ); + } + + final inspiredO2 = OpenCircuit( + fO2: fO2, + fHe: fHe, + ).inspiredAt(environment.pressureAtDepth(shallowerEnd)).pO2; + if (inspiredO2 < 0.16) { + issues.add( + PlanIssue( + type: PlanIssueType.hypoxicGas, + severity: PlanIssueSeverity.critical, + message: + 'Gas is hypoxic at ${shallowerEnd.toStringAsFixed(0)} m ' + '(ppO2 ${inspiredO2.toStringAsFixed(2)} bar)', + atDepth: shallowerEnd, + segmentId: segment.id, + value: inspiredO2, + threshold: 0.16, + ), + ); + } + + final end = segment.gasMix.end(deeperEnd, o2Narcotic: config.o2Narcotic); + if (end > config.endLimitMeters) { + issues.add( + PlanIssue( + type: PlanIssueType.endExceeded, + severity: PlanIssueSeverity.warning, + message: + 'END ${end.toStringAsFixed(0)} m exceeds ' + '${config.endLimitMeters.toStringAsFixed(0)} m', + atDepth: deeperEnd, + segmentId: segment.id, + value: end, + threshold: config.endLimitMeters, + ), + ); + } + + final density = gasDensityGPerL( + fO2: fO2, + fHe: fHe, + ambientPressureBar: environment.pressureAtDepth(deeperEnd), + ); + if (density > gasDensityCriticalGPerL) { + issues.add( + PlanIssue( + type: PlanIssueType.gasDensityCritical, + severity: PlanIssueSeverity.critical, + message: + 'Gas density ${density.toStringAsFixed(1)} g/L exceeds the ' + 'hard limit', + atDepth: deeperEnd, + segmentId: segment.id, + value: density, + threshold: gasDensityCriticalGPerL, + ), + ); + } else if (density > gasDensityWarnGPerL) { + issues.add( + PlanIssue( + type: PlanIssueType.gasDensityHigh, + severity: PlanIssueSeverity.warning, + message: + 'Gas density ${density.toStringAsFixed(1)} g/L exceeds the ' + 'recommended limit', + atDepth: deeperEnd, + segmentId: segment.id, + value: density, + threshold: gasDensityWarnGPerL, + ), + ); + } + } + + if (cns >= 100) { + issues.add( + PlanIssue( + type: PlanIssueType.cnsCritical, + severity: PlanIssueSeverity.critical, + message: 'CNS reaches ${cns.toStringAsFixed(0)}%', + value: cns, + threshold: 100, + ), + ); + } else if (cns >= config.cnsWarningThreshold) { + issues.add( + PlanIssue( + type: PlanIssueType.cnsWarning, + severity: PlanIssueSeverity.warning, + message: 'CNS reaches ${cns.toStringAsFixed(0)}%', + value: cns, + threshold: config.cnsWarningThreshold.toDouble(), + ), + ); + } + if (otu > config.otuLimit) { + issues.add( + PlanIssue( + type: PlanIssueType.otuHigh, + severity: PlanIssueSeverity.warning, + message: 'OTU ${otu.toStringAsFixed(0)} exceeds the daily guideline', + value: otu, + threshold: config.otuLimit, + ), + ); + } + + for (final usage in tankUsages) { + final remaining = usage.remainingPressure; + if (remaining == null) continue; + if (remaining <= 0) { + issues.add( + PlanIssue( + type: PlanIssueType.gasOut, + severity: PlanIssueSeverity.critical, + message: 'Tank runs out of gas', + segmentId: usage.tankId, + value: remaining, + threshold: 0, + ), + ); + } else if (usage.reserveViolation) { + issues.add( + PlanIssue( + type: PlanIssueType.gasReserveViolation, + severity: PlanIssueSeverity.alert, + message: + 'Tank ends below the ' + '${plan.reservePressure.toStringAsFixed(0)} bar reserve', + segmentId: usage.tankId, + value: remaining, + threshold: plan.reservePressure, + ), + ); + } + } + + final inDeco = segmentOutcomes.any((s) => s.inDeco); + if (inDeco && !_hasDecoGas(plan)) { + issues.add( + const PlanIssue( + type: PlanIssueType.ndlExceededNoDecoGas, + severity: PlanIssueSeverity.alert, + message: + 'Plan incurs decompression with no dedicated deco gas carried', + ), + ); + } + + issues.sort((a, b) => b.severity.index.compareTo(a.severity.index)); + return issues; + } + + /// True when a deco/stage tank richer than the back gas is carried. + bool _hasDecoGas(domain.DivePlan plan) { + if (plan.tanks.isEmpty) return false; + final backGas = plan.tanks + .firstWhere( + (t) => t.role == TankRole.backGas, + orElse: () => plan.tanks.first, + ) + .gasMix + .o2; + return plan.tanks.any( + (t) => + (t.role == TankRole.deco || t.role == TankRole.stage) && + t.gasMix.o2 > backGas, + ); + } + + AscentGasPlan _ascentPlanFor(List tanks) { + if (tanks.isEmpty) { + return FixedAscentGas(fN2: 0.7902); + } + return OptimalOcAscentGas( + maxPpO2: config.ppO2Deco, + gases: [ + for (final tank in tanks) + AvailableGas( + fN2: (100.0 - tank.gasMix.o2 - tank.gasMix.he) / 100.0, + fHe: tank.gasMix.he / 100.0, + maxPpO2Mod: O2ToxicityCalculator.calculateMod( + tank.gasMix.o2 / 100.0, + maxPpO2: config.ppO2Deco, + ), + ), + ], + ); + } + + List _mapStops( + DecoSchedule schedule, + domain.DivePlan plan, + AscentGasPlan ascentPlan, + double fromDepth, + int segmentsRuntime, + ) { + final stops = []; + var arrival = segmentsRuntime; + var depth = fromDepth; + for (final stop in schedule.stops) { + arrival += ((depth - stop.depthMeters) / plan.ascentRate * 60).round(); + final gas = ascentPlan.gasForDepth(stop.depthMeters); + final fO2 = 1.0 - gas.fN2 - gas.fHe; + stops.add( + PlanStop( + depthMeters: stop.depthMeters, + durationSeconds: stop.durationSeconds, + airBreakSeconds: stop.airBreakSeconds, + gasFO2: fO2, + gasFHe: gas.fHe, + tankId: _tankForGas(plan.tanks, fO2, gas.fHe), + arrivalRuntimeSeconds: arrival, + ), + ); + arrival += stop.durationSeconds; + depth = stop.depthMeters; + } + return stops; + } + + /// The carried tank whose mix matches the stop gas (deco/stage roles win + /// ties so the back gas is not charged for deco stops it did not supply). + String? _tankForGas(List tanks, double fO2, double fHe) { + DiveTank? match; + for (final tank in tanks) { + final tankFO2 = tank.gasMix.o2 / 100.0; + final tankFHe = tank.gasMix.he / 100.0; + if ((tankFO2 - fO2).abs() < 0.005 && (tankFHe - fHe).abs() < 0.005) { + final isDeco = + tank.role == TankRole.deco || tank.role == TankRole.stage; + if (match == null || + (isDeco && + match.role != TankRole.deco && + match.role != TankRole.stage)) { + match = tank; + } + } + } + return match?.id; + } +} diff --git a/lib/features/planner/presentation/providers/plan_repository_providers.dart b/lib/features/planner/presentation/providers/plan_repository_providers.dart new file mode 100644 index 000000000..9f7e161e1 --- /dev/null +++ b/lib/features/planner/presentation/providers/plan_repository_providers.dart @@ -0,0 +1,18 @@ +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; + +/// Repository singleton for saved dive plans. +final divePlanRepositoryProvider = Provider( + (ref) => DivePlanRepository(), +); + +/// Saved-plans list (newest first), live across saves/deletes/sync. +final divePlanSummariesProvider = FutureProvider>(( + ref, +) async { + final repository = ref.watch(divePlanRepositoryProvider); + ref.invalidateSelfWhen(repository.watchPlanChanges()); + return repository.getAllPlanSummaries(); +}); diff --git a/test/core/database/migration_v100_dive_plans_test.dart b/test/core/database/migration_v100_dive_plans_test.dart new file mode 100644 index 000000000..4f8d80ddd --- /dev/null +++ b/test/core/database/migration_v100_dive_plans_test.dart @@ -0,0 +1,175 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +void main() { + test('v100 creates the three dive plan tables with hlc columns', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 99'); + // Minimal parents so FK references resolve. + rawDb.execute('CREATE TABLE divers (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dive_sites (id TEXT NOT NULL PRIMARY KEY)'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(() => db.close()); + + for (final table in [ + 'dive_plans', + 'dive_plan_tanks', + 'dive_plan_segments', + ]) { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('id'), reason: '$table missing id'); + expect(names, contains('hlc'), reason: '$table missing hlc'); + expect( + names, + contains('created_at'), + reason: '$table missing created_at', + ); + expect( + names, + contains('updated_at'), + reason: '$table missing updated_at', + ); + } + + final planCols = await db + .customSelect("PRAGMA table_info('dive_plans')") + .get(); + final planNames = planCols.map((c) => c.read('name')).toSet(); + expect( + planNames, + containsAll([ + 'diver_id', + 'name', + 'notes', + 'mode', + 'site_id', + 'source_dive_id', + 'linked_dive_id', + 'altitude', + 'water_type', + 'gf_low', + 'gf_high', + 'descent_rate', + 'ascent_rate', + 'last_stop_depth', + 'gas_switch_stop_seconds', + 'air_break_o2_seconds', + 'air_break_break_seconds', + 'sac_bottom', + 'sac_deco', + 'sac_stressed', + 'reserve_pressure', + 'surface_interval_seconds', + 'setpoint_low', + 'setpoint_high', + 'setpoint_switch_depth', + 'deviation_depth_delta', + 'deviation_time_minutes', + 'turn_pressure_rule', + 'turn_pressure_fraction', + 'summary_max_depth', + 'summary_runtime_seconds', + 'summary_tts_seconds', + ]), + ); + + final tankCols = await db + .customSelect("PRAGMA table_info('dive_plan_tanks')") + .get(); + final tankNames = tankCols.map((c) => c.read('name')).toSet(); + expect( + tankNames, + containsAll([ + 'plan_id', + 'name', + 'volume', + 'working_pressure', + 'start_pressure', + 'gas_o2', + 'gas_he', + 'role', + 'material', + 'preset_name', + 'sort_order', + ]), + ); + + final segCols = await db + .customSelect("PRAGMA table_info('dive_plan_segments')") + .get(); + final segNames = segCols.map((c) => c.read('name')).toSet(); + expect( + segNames, + containsAll([ + 'plan_id', + 'type', + 'start_depth', + 'end_depth', + 'duration_seconds', + 'tank_id', + 'gas_o2', + 'gas_he', + 'rate', + 'switch_to_tank_id', + 'sort_order', + ]), + ); + + final indexes = await db + .customSelect( + "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_dive_plan%'", + ) + .get(); + final indexNames = indexes.map((r) => r.read('name')).toSet(); + expect(indexNames, contains('idx_dive_plan_tanks_plan_id')); + expect(indexNames, contains('idx_dive_plan_segments_plan_id')); + }); + + test( + 'recovers databases stranded at v100 by parallel-branch collisions', + () async { + // A live database that claimed user_version 100 on a parallel branch + // (without the plan tables) must be healed by the beforeOpen re-assert. + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 100'); + rawDb.execute('CREATE TABLE divers (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute( + 'CREATE TABLE dive_sites (id TEXT NOT NULL PRIMARY KEY)', + ); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(() => db.close()); + + for (final table in [ + 'dive_plans', + 'dive_plan_tanks', + 'dive_plan_segments', + ]) { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + expect(cols, isNotEmpty, reason: '$table was not created'); + } + }, + ); + + test('v100 is the current schema version and in the ladder', () { + expect(AppDatabase.currentSchemaVersion, 100); + expect(AppDatabase.migrationVersions, contains(100)); + }); + + test('fresh database exposes the plan tables via Drift', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(() => db.close()); + expect(await db.select(db.divePlans).get(), isEmpty); + expect(await db.select(db.divePlanTanks).get(), isEmpty); + expect(await db.select(db.divePlanSegments).get(), isEmpty); + }); +} diff --git a/test/core/deco/gas_density_test.dart b/test/core/deco/gas_density_test.dart new file mode 100644 index 000000000..59d3b6e9d --- /dev/null +++ b/test/core/deco/gas_density_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/deco/gas_density.dart'; + +void main() { + group('gasDensityGPerL', () { + test('air at 40 m matches the python vector', () { + // python3: (0.21*32 + 0.79*28)/24.04 * 5.0 = 5.998336106489185 + expect( + gasDensityGPerL(fO2: 0.21, fHe: 0.0, ambientPressureBar: 5.0), + closeTo(5.998336106489185, 1e-9), + ); + }); + + test('air at 45 m exceeds the hard limit', () { + // python3: 6.598169717138103 + final density = gasDensityGPerL( + fO2: 0.21, + fHe: 0.0, + ambientPressureBar: 5.5, + ); + expect(density, closeTo(6.598169717138103, 1e-9)); + expect(density, greaterThan(gasDensityCriticalGPerL)); + }); + + test('Tx18/45 at 60 m sits just over the recommended limit', () { + // python3: (0.18*32 + 0.37*28 + 0.45*4)/24.04 * 7.0 = 5.2179... + // A well-known edge case: standard trimix at 60 m is marginally above + // the 5.2 g/L recommendation but well under the 6.2 hard limit. + final density = gasDensityGPerL( + fO2: 0.18, + fHe: 0.45, + ambientPressureBar: 7.0, + ); + expect(density, closeTo(5.218, 0.001)); + expect(density, lessThan(gasDensityCriticalGPerL)); + }); + + test('thresholds are the published 5.2 / 6.2 g/L values', () { + expect(gasDensityWarnGPerL, 5.2); + expect(gasDensityCriticalGPerL, 6.2); + }); + }); +} diff --git a/test/core/services/sync/dive_plan_sync_round_trip_test.dart b/test/core/services/sync/dive_plan_sync_round_trip_test.dart new file mode 100644 index 000000000..68e50008f --- /dev/null +++ b/test/core/services/sync/dive_plan_sync_round_trip_test.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; + +import '../../../helpers/test_database.dart'; + +DivePlansCompanion _plan(String id) { + final now = DateTime.now().millisecondsSinceEpoch; + return DivePlansCompanion.insert( + id: id, + name: 'Wreck 60 m', + gfLow: 50, + gfHigh: 80, + createdAt: now, + updatedAt: now, + mode: const Value('oc'), + altitude: const Value(700.0), + waterType: const Value('salt'), + sacBottom: const Value(16.0), + summaryMaxDepth: const Value(60.0), + summaryRuntimeSeconds: const Value(74 * 60), + summaryTtsSeconds: const Value(39 * 60), + hlc: const Value('0001-test-hlc'), + ); +} + +DivePlanTanksCompanion _tank(String id, String planId, {double o2 = 21}) { + final now = DateTime.now().millisecondsSinceEpoch; + return DivePlanTanksCompanion.insert( + id: id, + planId: planId, + createdAt: now, + updatedAt: now, + volume: const Value(11.1), + startPressure: const Value(207.0), + gasO2: Value(o2), + hlc: const Value('0001-test-hlc'), + ); +} + +DivePlanSegmentsCompanion _segment( + String id, + String planId, + String tankId, + int order, +) { + final now = DateTime.now().millisecondsSinceEpoch; + return DivePlanSegmentsCompanion.insert( + id: id, + planId: planId, + type: 'bottom', + startDepth: 60.0, + endDepth: 60.0, + durationSeconds: 25 * 60, + tankId: tankId, + gasO2: 18.0, + gasHe: 45.0, + sortOrder: Value(order), + createdAt: now, + updatedAt: now, + hlc: const Value('0001-test-hlc'), + ); +} + +void main() { + group('Dive plan sync round trip (FK ON)', () { + late AppDatabase db; + + setUp(() async { + db = await setUpTestDatabase(); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + test( + 'plan + tanks + segments survive export and JSON round-trip', + () async { + await db.into(db.divePlans).insert(_plan('plan-1')); + await db.into(db.divePlanTanks).insert(_tank('tank-1', 'plan-1')); + await db + .into(db.divePlanTanks) + .insert(_tank('tank-2', 'plan-1', o2: 50)); + await db + .into(db.divePlanSegments) + .insert(_segment('seg-1', 'plan-1', 'tank-1', 0)); + await db + .into(db.divePlanSegments) + .insert(_segment('seg-2', 'plan-1', 'tank-1', 1)); + await db + .into(db.divePlanSegments) + .insert(_segment('seg-3', 'plan-1', 'tank-2', 2)); + + final serializer = SyncDataSerializer(); + final syncRepository = SyncRepository(); + final deviceId = await syncRepository.getDeviceId(); + final deletions = await syncRepository.getAllDeletions(); + final payload = await serializer.exportData( + deviceId: deviceId, + lastSyncTimestamp: null, + deletions: deletions, + ); + final data = payload.data; + expect(data.divePlans, hasLength(1)); + expect(data.divePlanTanks, hasLength(2)); + expect(data.divePlanSegments, hasLength(3)); + + // JSON round trip preserves everything. + final decoded = SyncData.fromJson( + jsonDecode(jsonEncode(data.toJson())) as Map, + ); + expect(decoded.divePlans.single['name'], 'Wreck 60 m'); + expect(decoded.divePlans.single['waterType'], 'salt'); + expect(decoded.divePlanTanks, hasLength(2)); + expect(decoded.divePlanSegments, hasLength(3)); + + // Apply into a second FK-ON database, parents before children. + // The serializer reads DatabaseService.instance.database, so point the + // service at the target database for the apply phase. + final db2 = AppDatabase(NativeDatabase.memory()); + addTearDown(() => db2.close()); + DatabaseService.instance.resetForTesting(); + DatabaseService.instance.setTestDatabase(db2); + await db2.customStatement('PRAGMA foreign_keys = ON'); + final serializer2 = SyncDataSerializer(); + for (final record in decoded.divePlans) { + await serializer2.upsertRecord('divePlans', record); + } + for (final record in decoded.divePlanTanks) { + await serializer2.upsertRecord('divePlanTanks', record); + } + for (final record in decoded.divePlanSegments) { + await serializer2.upsertRecord('divePlanSegments', record); + } + + final plans = await db2.select(db2.divePlans).get(); + expect(plans.single.summaryTtsSeconds, 39 * 60); + expect(await db2.select(db2.divePlanTanks).get(), hasLength(2)); + expect(await db2.select(db2.divePlanSegments).get(), hasLength(3)); + }, + ); + }); +} diff --git a/test/features/dive_planner/save_load_round_trip_test.dart b/test/features/dive_planner/save_load_round_trip_test.dart new file mode 100644 index 000000000..b04da488d --- /dev/null +++ b/test/features/dive_planner/save_load_round_trip_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/map_style.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_repository_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../helpers/test_database.dart'; + +class _TestSettingsNotifier extends StateNotifier + implements SettingsNotifier { + _TestSettingsNotifier() : super(const AppSettings()); + + @override + Future setMapStyle(MapStyle style) async => + state = state.copyWith(mapStyle: style); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late ProviderContainer container; + + setUp(() async { + await setUpTestDatabase(); + container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + ); + addTearDown(container.dispose); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + test('save, reset, and load restores the planner state', () async { + final notifier = container.read(divePlanNotifierProvider.notifier); + + notifier.addSimplePlan(maxDepth: 30.0, bottomTimeMinutes: 20); + notifier.updateName('Round trip plan'); + notifier.updateSacRate(17.5); + final savedId = container.read(divePlanNotifierProvider).id; + final savedSegmentCount = container + .read(divePlanNotifierProvider) + .segments + .length; + expect(savedSegmentCount, greaterThan(0)); + + await notifier.save( + summary: const PlanSummaryData( + maxDepth: 30.0, + runtimeSeconds: 30 * 60, + ttsSeconds: 4 * 60, + ), + ); + expect(container.read(divePlanNotifierProvider).isDirty, isFalse); + + // The summaries list sees the saved plan. + final summaries = await container.read(divePlanSummariesProvider.future); + expect(summaries.map((s) => s.id), contains(savedId)); + expect( + summaries.firstWhere((s) => s.id == savedId).name, + 'Round trip plan', + ); + + // Reset, then load back by id. + notifier.newPlan(); + expect(container.read(divePlanNotifierProvider).id, isNot(savedId)); + + final loaded = await notifier.loadPlanById(savedId); + expect(loaded, isTrue); + final restored = container.read(divePlanNotifierProvider); + expect(restored.id, savedId); + expect(restored.name, 'Round trip plan'); + expect(restored.sacRate, 17.5); + expect(restored.segments, hasLength(savedSegmentCount)); + expect(restored.isDirty, isFalse); + }); + + test('loadPlanById returns false for an unknown id', () async { + final notifier = container.read(divePlanNotifierProvider.notifier); + expect(await notifier.loadPlanById('nope'), isFalse); + }); +} diff --git a/test/features/planner/dive_plan_entity_test.dart b/test/features/planner/dive_plan_entity_test.dart new file mode 100644 index 000000000..00eb22baa --- /dev/null +++ b/test/features/planner/dive_plan_entity_test.dart @@ -0,0 +1,243 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; + +DivePlan _plan({ + double sacBottom = 15.0, + double? sacDeco, + double? sacStressed, + List segments = const [], +}) { + return DivePlan( + id: 'p1', + name: 'Test plan', + gfLow: 50, + gfHigh: 80, + sacBottom: sacBottom, + sacDeco: sacDeco, + sacStressed: sacStressed, + segments: segments, + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +void main() { + group('DivePlan', () { + test('SAC defaults derive from bottom SAC', () { + final plan = _plan(sacBottom: 15.0); + expect(plan.sacDecoEffective, closeTo(12.0, 1e-9)); + expect(plan.sacStressedEffective, closeTo(37.5, 1e-9)); + }); + + test('explicit SAC values override the derived defaults', () { + final plan = _plan(sacBottom: 15.0, sacDeco: 14.0, sacStressed: 40.0); + expect(plan.sacDecoEffective, 14.0); + expect(plan.sacStressedEffective, 40.0); + }); + + test('maxDepth spans segment start and end depths', () { + const gas = GasMix(o2: 21); + final plan = _plan( + segments: [ + PlanSegment.descent( + id: 's1', + targetDepth: 42.0, + tankId: 't1', + gasMix: gas, + ), + PlanSegment.bottom( + id: 's2', + depth: 42.0, + durationMinutes: 20, + tankId: 't1', + gasMix: gas, + ), + ], + ); + expect(plan.maxDepth, 42.0); + expect(_plan().maxDepth, 0.0); + }); + + test('copyWith clear-flags null out nullable fields', () { + final plan = _plan().copyWith( + airBreaks: const AirBreakPolicy(), + surfaceInterval: const Duration(hours: 1), + sourceDiveId: 'dive-1', + ); + expect(plan.airBreaks, isNotNull); + final cleared = plan.copyWith( + clearAirBreaks: true, + clearSurfaceInterval: true, + clearSourceDiveId: true, + ); + expect(cleared.airBreaks, isNull); + expect(cleared.surfaceInterval, isNull); + expect(cleared.sourceDiveId, isNull); + // Untouched fields survive. + expect(cleared.name, 'Test plan'); + expect(cleared.gfHigh, 80); + }); + + test('copyWith carries every field through', () { + const gas = GasMix(o2: 32); + const tank = DiveTank(id: 't1', volume: 11.1, gasMix: gas); + final segment = PlanSegment.bottom( + id: 's1', + depth: 30, + durationMinutes: 20, + tankId: 't1', + gasMix: gas, + ); + final updated = _plan().copyWith( + id: 'p2', + name: 'Renamed', + notes: 'notes', + siteId: 'site-1', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 2), + mode: PlanMode.ccr, + altitude: 1500.0, + waterType: WaterType.fresh, + gfLow: 35, + gfHigh: 75, + descentRate: 20.0, + ascentRate: 10.0, + lastStopDepth: 6.0, + gasSwitchStopSeconds: 60, + airBreaks: const AirBreakPolicy(o2Seconds: 900, breakSeconds: 300), + sacBottom: 16.0, + sacDeco: 12.0, + sacStressed: 40.0, + reservePressure: 60.0, + surfaceInterval: const Duration(hours: 2), + sourceDiveId: 'src', + linkedDiveId: 'link', + setpointLow: 0.7, + setpointHigh: 1.4, + setpointSwitchDepth: 12.0, + deviationDepthDelta: 6.0, + deviationTimeMinutes: 10, + turnPressureRule: TurnPressureRule.thirds, + turnPressureFraction: 0.4, + segments: [segment], + tanks: const [tank], + ); + + expect(updated.id, 'p2'); + expect(updated.name, 'Renamed'); + expect(updated.notes, 'notes'); + expect(updated.siteId, 'site-1'); + expect(updated.createdAt, DateTime(2026, 1, 1)); + expect(updated.updatedAt, DateTime(2026, 1, 2)); + expect(updated.mode, PlanMode.ccr); + expect(updated.altitude, 1500.0); + expect(updated.waterType, WaterType.fresh); + expect(updated.gfLow, 35); + expect(updated.gfHigh, 75); + expect(updated.descentRate, 20.0); + expect(updated.ascentRate, 10.0); + expect(updated.lastStopDepth, 6.0); + expect(updated.gasSwitchStopSeconds, 60); + expect(updated.airBreaks?.o2Seconds, 900); + expect(updated.sacBottom, 16.0); + expect(updated.sacDeco, 12.0); + expect(updated.sacStressed, 40.0); + expect(updated.reservePressure, 60.0); + expect(updated.surfaceInterval, const Duration(hours: 2)); + expect(updated.sourceDiveId, 'src'); + expect(updated.linkedDiveId, 'link'); + expect(updated.setpointLow, 0.7); + expect(updated.setpointHigh, 1.4); + expect(updated.setpointSwitchDepth, 12.0); + expect(updated.deviationDepthDelta, 6.0); + expect(updated.deviationTimeMinutes, 10); + expect(updated.turnPressureRule, TurnPressureRule.thirds); + expect(updated.turnPressureFraction, 0.4); + expect(updated.segments, [segment]); + expect(updated.tanks, const [tank]); + }); + + test('every clear-flag nulls its field', () { + final full = _plan().copyWith( + siteId: 'site', + altitude: 100, + waterType: WaterType.salt, + airBreaks: const AirBreakPolicy(), + sacDeco: 12, + sacStressed: 40, + surfaceInterval: const Duration(hours: 1), + sourceDiveId: 'src', + linkedDiveId: 'link', + setpointLow: 0.7, + setpointHigh: 1.3, + setpointSwitchDepth: 10, + turnPressureRule: TurnPressureRule.halves, + turnPressureFraction: 0.5, + ); + final cleared = full.copyWith( + clearSiteId: true, + clearAltitude: true, + clearWaterType: true, + clearAirBreaks: true, + clearSacDeco: true, + clearSacStressed: true, + clearSurfaceInterval: true, + clearSourceDiveId: true, + clearLinkedDiveId: true, + clearSetpointLow: true, + clearSetpointHigh: true, + clearSetpointSwitchDepth: true, + clearTurnPressureRule: true, + clearTurnPressureFraction: true, + ); + expect(cleared.siteId, isNull); + expect(cleared.altitude, isNull); + expect(cleared.waterType, isNull); + expect(cleared.airBreaks, isNull); + expect(cleared.sacDeco, isNull); + expect(cleared.sacStressed, isNull); + expect(cleared.surfaceInterval, isNull); + expect(cleared.sourceDiveId, isNull); + expect(cleared.linkedDiveId, isNull); + expect(cleared.setpointLow, isNull); + expect(cleared.setpointHigh, isNull); + expect(cleared.setpointSwitchDepth, isNull); + expect(cleared.turnPressureRule, isNull); + expect(cleared.turnPressureFraction, isNull); + }); + + test('equality tracks props', () { + expect(_plan(), equals(_plan())); + expect(_plan().hashCode, _plan().hashCode); + expect(_plan(sacBottom: 15.0), isNot(equals(_plan(sacBottom: 16.0)))); + }); + }); + + group('DivePlanSummary', () { + DivePlanSummary summary({String name = 'Reef'}) => DivePlanSummary( + id: 'p1', + name: name, + updatedAt: DateTime(2026, 7, 5), + maxDepth: 30, + runtimeSeconds: 2700, + ttsSeconds: 300, + mode: PlanMode.oc, + ); + + test('equality tracks props', () { + expect(summary(), equals(summary())); + expect(summary().hashCode, summary().hashCode); + expect(summary(), isNot(equals(summary(name: 'Wreck')))); + expect(summary().props, hasLength(7)); + }); + + test('mode defaults to open circuit', () { + final s = DivePlanSummary(id: 'p', name: 'n', updatedAt: DateTime(2026)); + expect(s.mode, PlanMode.oc); + }); + }); +} diff --git a/test/features/planner/dive_plan_repository_test.dart b/test/features/planner/dive_plan_repository_test.dart new file mode 100644 index 000000000..0814a062f --- /dev/null +++ b/test/features/planner/dive_plan_repository_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/database/database.dart' as db; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; + +import '../../helpers/test_database.dart'; + +domain.DivePlan _fullPlan() { + const backGas = GasMix(o2: 18, he: 45); + const decoGas = GasMix(o2: 50); + const tank1 = DiveTank( + id: 'tank-1', + name: 'D12', + volume: 24.0, + workingPressure: 232.0, + startPressure: 232.0, + gasMix: backGas, + role: TankRole.backGas, + material: TankMaterial.steel, + ); + const tank2 = DiveTank( + id: 'tank-2', + name: 'S80', + volume: 11.1, + startPressure: 207.0, + gasMix: decoGas, + role: TankRole.deco, + ); + return domain.DivePlan( + id: 'plan-1', + name: 'Wreck 60 m', + notes: 'Stern first', + mode: domain.PlanMode.oc, + altitude: 700.0, + waterType: WaterType.salt, + gfLow: 45, + gfHigh: 80, + descentRate: 20.0, + ascentRate: 9.0, + lastStopDepth: 6.0, + gasSwitchStopSeconds: 120, + airBreaks: const AirBreakPolicy(o2Seconds: 720, breakSeconds: 360), + sacBottom: 16.0, + sacDeco: 13.0, + reservePressure: 55.0, + surfaceInterval: const Duration(hours: 2), + deviationDepthDelta: 6.0, + deviationTimeMinutes: 5, + turnPressureRule: domain.TurnPressureRule.thirds, + tanks: const [tank1, tank2], + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: 60.0, + tankId: 'tank-1', + gasMix: backGas, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: 60.0, + durationMinutes: 25, + tankId: 'tank-1', + gasMix: backGas, + order: 1, + ), + ], + createdAt: DateTime(2026, 7, 5, 10), + updatedAt: DateTime(2026, 7, 5, 10), + ); +} + +void main() { + late DivePlanRepository repository; + late db.AppDatabase database; + + setUp(() async { + database = await setUpTestDatabase(); + repository = DivePlanRepository(); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + group('DivePlanRepository', () { + test('save then get round-trips every field', () async { + final plan = _fullPlan(); + await repository.savePlan(plan); + final loaded = await repository.getPlan('plan-1'); + + expect(loaded, isNotNull); + expect(loaded!.name, 'Wreck 60 m'); + expect(loaded.notes, 'Stern first'); + expect(loaded.mode, domain.PlanMode.oc); + expect(loaded.altitude, 700.0); + expect(loaded.waterType, WaterType.salt); + expect(loaded.gfLow, 45); + expect(loaded.gfHigh, 80); + expect(loaded.descentRate, 20.0); + expect(loaded.lastStopDepth, 6.0); + expect(loaded.gasSwitchStopSeconds, 120); + expect(loaded.airBreaks?.o2Seconds, 720); + expect(loaded.airBreaks?.breakSeconds, 360); + expect(loaded.sacBottom, 16.0); + expect(loaded.sacDeco, 13.0); + expect(loaded.sacStressed, isNull); + expect(loaded.reservePressure, 55.0); + expect(loaded.surfaceInterval, const Duration(hours: 2)); + expect(loaded.turnPressureRule, domain.TurnPressureRule.thirds); + expect(loaded.tanks, hasLength(2)); + expect(loaded.tanks.first.gasMix.he, 45); + expect(loaded.tanks.first.material, TankMaterial.steel); + expect(loaded.tanks.last.role, TankRole.deco); + expect(loaded.segments, hasLength(2)); + expect(loaded.segments.first.type, SegmentType.descent); + expect(loaded.segments.last.durationSeconds, 25 * 60); + }); + + test('summaries return saved numbers without children', () async { + await repository.savePlan( + _fullPlan(), + summary: const PlanSummaryData( + maxDepth: 60.0, + runtimeSeconds: 74 * 60, + ttsSeconds: 39 * 60, + ), + ); + final summaries = await repository.getAllPlanSummaries(); + expect(summaries, hasLength(1)); + expect(summaries.single.maxDepth, 60.0); + expect(summaries.single.runtimeSeconds, 74 * 60); + expect(summaries.single.ttsSeconds, 39 * 60); + }); + + test('re-save with a removed segment tombstones it', () async { + final plan = _fullPlan(); + await repository.savePlan(plan); + final shorter = plan.copyWith(segments: [plan.segments.first]); + await repository.savePlan(shorter); + + final loaded = await repository.getPlan('plan-1'); + expect(loaded!.segments, hasLength(1)); + + final deletions = await database.select(database.deletionLog).get(); + final segmentTombstones = deletions.where( + (d) => d.entityType == 'divePlanSegments' && d.recordId == 'seg-2', + ); + expect(segmentTombstones, hasLength(1)); + }); + + test('deletePlan removes all rows and tombstones each', () async { + await repository.savePlan(_fullPlan()); + await repository.deletePlan('plan-1'); + + expect(await repository.getPlan('plan-1'), isNull); + expect(await database.select(database.divePlanTanks).get(), isEmpty); + expect(await database.select(database.divePlanSegments).get(), isEmpty); + + final deletions = await database.select(database.deletionLog).get(); + final types = deletions.map((d) => '${d.entityType}:${d.recordId}'); + expect( + types, + containsAll([ + 'divePlans:plan-1', + 'divePlanTanks:tank-1', + 'divePlanTanks:tank-2', + 'divePlanSegments:seg-1', + 'divePlanSegments:seg-2', + ]), + ); + }); + + test('duplicatePlan remaps ids and suffixes the name', () async { + await repository.savePlan(_fullPlan()); + final copy = await repository.duplicatePlan('plan-1'); + + expect(copy, isNotNull); + expect(copy!.id, isNot('plan-1')); + expect(copy.name, 'Wreck 60 m (copy)'); + expect(copy.tanks.map((t) => t.id), isNot(contains('tank-1'))); + // Every segment points at one of the NEW tank ids. + final newTankIds = copy.tanks.map((t) => t.id).toSet(); + for (final segment in copy.segments) { + expect(newTankIds, contains(segment.tankId)); + } + expect(await repository.getAllPlanSummaries(), hasLength(2)); + }); + + test('savePlan marks plan and children pending for sync', () async { + await repository.savePlan(_fullPlan()); + final pending = await database.select(database.syncRecords).get(); + final keys = pending.map((r) => '${r.entityType}:${r.recordId}'); + expect( + keys, + containsAll([ + 'divePlans:plan-1', + 'divePlanTanks:tank-1', + 'divePlanSegments:seg-1', + ]), + ); + // HLC stamped on the plan row. + final planRow = await database.select(database.divePlans).getSingle(); + expect(planRow.hlc, isNotNull); + }); + + test( + 're-save preserves child createdAt while advancing updatedAt', + () async { + final repo = DivePlanRepository(); + await repo.savePlan(_fullPlan()); + + final originalTank = await (database.select( + database.divePlanTanks, + )..where((t) => t.id.equals('tank-1'))).getSingle(); + final originalSegment = await (database.select( + database.divePlanSegments, + )..where((t) => t.id.equals('seg-1'))).getSingle(); + + // Let the clock advance so a regressed "createdAt = now" would differ. + await Future.delayed(const Duration(milliseconds: 5)); + // Edit and re-save (rename keeps the same child rows). + await repo.savePlan(_fullPlan().copyWith(name: 'Wreck 60 m (edited)')); + + final tankAfter = await (database.select( + database.divePlanTanks, + )..where((t) => t.id.equals('tank-1'))).getSingle(); + final segmentAfter = await (database.select( + database.divePlanSegments, + )..where((t) => t.id.equals('seg-1'))).getSingle(); + + expect( + tankAfter.createdAt, + originalTank.createdAt, + reason: 'tank createdAt must survive an edit', + ); + expect( + segmentAfter.createdAt, + originalSegment.createdAt, + reason: 'segment createdAt must survive an edit', + ); + expect( + tankAfter.updatedAt, + greaterThanOrEqualTo(originalTank.updatedAt), + reason: 'updatedAt still advances on edit', + ); + }, + ); + }); +} diff --git a/test/features/planner/dive_plan_state_mapper_test.dart b/test/features/planner/dive_plan_state_mapper_test.dart new file mode 100644 index 000000000..3292c6c47 --- /dev/null +++ b/test/features/planner/dive_plan_state_mapper_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_result.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/services/dive_plan_state_mapper.dart'; + +void main() { + const gas = GasMix(o2: 21); + const tank = DiveTank( + id: 't1', + volume: 11.1, + startPressure: 200, + gasMix: gas, + ); + final segments = [ + PlanSegment.bottom( + id: 's1', + depth: 30.0, + durationMinutes: 20, + tankId: 't1', + gasMix: gas, + ), + ]; + + DivePlanState state() => DivePlanState( + id: 'p1', + name: 'Reef dive', + notes: 'Easy one', + segments: segments, + tanks: [tank], + gfLow: 45, + gfHigh: 85, + sacRate: 17.0, + reservePressure: 60.0, + altitude: 300.0, + surfaceInterval: const Duration(hours: 1), + createdAt: DateTime(2026, 7, 1), + updatedAt: DateTime(2026, 7, 5), + ); + + test('state -> plan -> state round-trips the shared fields', () { + final plan = divePlanFromState(state()); + expect(plan.name, 'Reef dive'); + expect(plan.sacBottom, 17.0); + expect(plan.gfLow, 45); + expect(plan.altitude, 300.0); + expect(plan.surfaceInterval, const Duration(hours: 1)); + + final restored = stateFromDivePlan(plan); + expect(restored.name, state().name); + expect(restored.sacRate, state().sacRate); + expect(restored.gfHigh, state().gfHigh); + expect(restored.segments, state().segments); + expect(restored.tanks, state().tanks); + expect(restored.isDirty, isFalse); + }); + + test('existing plan preserves fields the legacy state does not carry', () { + final existing = domain.DivePlan( + id: 'p1', + name: 'Old name', + gfLow: 30, + gfHigh: 70, + mode: domain.PlanMode.ccr, + waterType: WaterType.fresh, + descentRate: 20.0, + airBreaks: const AirBreakPolicy(), + setpointHigh: 1.3, + turnPressureRule: domain.TurnPressureRule.thirds, + sourceDiveId: 'dive-9', + createdAt: DateTime(2026, 6, 1), + updatedAt: DateTime(2026, 6, 1), + ); + + final merged = divePlanFromState(state(), existing: existing); + // Legacy-owned fields come from the state... + expect(merged.name, 'Reef dive'); + expect(merged.gfLow, 45); + expect(merged.sacBottom, 17.0); + // ...while aggregate-only fields survive the cycle. + expect(merged.mode, domain.PlanMode.ccr); + expect(merged.waterType, WaterType.fresh); + expect(merged.descentRate, 20.0); + expect(merged.airBreaks, isNotNull); + expect(merged.setpointHigh, 1.3); + expect(merged.turnPressureRule, domain.TurnPressureRule.thirds); + expect(merged.sourceDiveId, 'dive-9'); + }); + + test('null state fields clear stale values from the existing plan', () { + final existing = domain.DivePlan( + id: 'p1', + name: 'Old', + gfLow: 30, + gfHigh: 70, + altitude: 2000.0, + siteId: 'site-1', + surfaceInterval: const Duration(hours: 3), + createdAt: DateTime(2026, 6, 1), + updatedAt: DateTime(2026, 6, 1), + ); + final cleared = state().copyWith( + clearAltitude: true, + clearSiteId: true, + clearSurfaceInterval: true, + ); + final merged = divePlanFromState(cleared, existing: existing); + expect(merged.altitude, isNull); + expect(merged.siteId, isNull); + expect(merged.surfaceInterval, isNull); + }); +} diff --git a/test/features/planner/dive_plan_sync_round_trip_test.dart b/test/features/planner/dive_plan_sync_round_trip_test.dart new file mode 100644 index 000000000..0a5176453 --- /dev/null +++ b/test/features/planner/dive_plan_sync_round_trip_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; +import 'package:submersion/core/services/sync/sync_service.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart'; + +import '../../helpers/fake_cloud_storage_provider.dart'; +import '../../helpers/sync_test_helpers.dart'; +import '../../helpers/test_database.dart'; + +void main() { + group('dive plan sync serialization', () { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + const gas = GasMix(o2: 32); + const tank = DiveTank( + id: 'tank-1', + name: 'Back gas', + volume: 11.1, + startPressure: 200.0, + gasMix: gas, + ); + + DivePlan plan() => DivePlan( + id: 'plan-1', + name: 'Sync plan', + gfLow: 40, + gfHigh: 80, + mode: PlanMode.oc, + tanks: const [tank], + segments: [ + PlanSegment.bottom( + id: 'seg-1', + depth: 30, + durationMinutes: 20, + tankId: 'tank-1', + gasMix: gas, + ), + ], + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); + + test('export includes the plan, tank, and segment rows', () async { + await DivePlanRepository().savePlan(plan()); + final serializer = SyncDataSerializer(); + + final changeset = await serializer.exportChangeset( + deviceId: 'device-a', + hlcWatermark: null, + deletions: const [], + ); + + expect(changeset.data.divePlans.map((r) => r['id']), contains('plan-1')); + expect( + changeset.data.divePlanTanks.map((r) => r['id']), + contains('tank-1'), + ); + expect( + changeset.data.divePlanSegments.map((r) => r['id']), + contains('seg-1'), + ); + // Field values survive the toJson export. + final planRow = changeset.data.divePlans.firstWhere( + (r) => r['id'] == 'plan-1', + ); + expect(planRow['name'], 'Sync plan'); + expect(planRow['gfLow'], 40); + }); + + test( + 'exported rows re-import through upsertRecord and fetchRecord', + () async { + await DivePlanRepository().savePlan(plan()); + final serializer = SyncDataSerializer(); + final changeset = await serializer.exportChangeset( + deviceId: 'device-a', + hlcWatermark: null, + deletions: const [], + ); + final planRow = changeset.data.divePlans.firstWhere( + (r) => r['id'] == 'plan-1', + ); + final tankRow = changeset.data.divePlanTanks.firstWhere( + (r) => r['id'] == 'tank-1', + ); + final segmentRow = changeset.data.divePlanSegments.firstWhere( + (r) => r['id'] == 'seg-1', + ); + + // Wipe the tables and re-apply as if received from a peer. + await serializer.deleteRecord('divePlanSegments', 'seg-1'); + await serializer.deleteRecord('divePlanTanks', 'tank-1'); + await serializer.deleteRecord('divePlans', 'plan-1'); + expect(await serializer.fetchRecord('divePlans', 'plan-1'), isNull); + + await serializer.upsertRecord('divePlans', planRow); + await serializer.upsertRecord('divePlanTanks', tankRow); + await serializer.upsertRecord('divePlanSegments', segmentRow); + + expect( + (await serializer.fetchRecord('divePlans', 'plan-1'))?['name'], + 'Sync plan', + ); + expect( + await serializer.fetchRecord('divePlanTanks', 'tank-1'), + isNotNull, + ); + expect( + await serializer.fetchRecord('divePlanSegments', 'seg-1'), + isNotNull, + ); + + // And the aggregate rebuilds from the re-imported rows. + final reloaded = await DivePlanRepository().getPlan('plan-1'); + expect(reloaded, isNotNull); + expect(reloaded!.tanks, hasLength(1)); + expect(reloaded.segments, hasLength(1)); + }, + ); + + test('a plan created on device A is restored on device B', () async { + final cloud = FakeCloudStorageProvider(); + SyncService buildService() => SyncService( + syncRepository: SyncRepository(), + serializer: SyncDataSerializer(), + cloudProvider: cloud, + ); + + // Device A: seed a plan and push (exercises the full export pipeline). + await DivePlanRepository().savePlan(plan()); + final push = await buildService().performSync(); + expect(push.isSuccess, isTrue, reason: 'device A push: ${push.message}'); + + // Impersonate a fresh device B and pull (exercises fetch/upsert/merge + // batch paths for the plan tables). + await DivePlanRepository().deletePlan('plan-1'); + await impersonateFreshDevice(); + expect(await DivePlanRepository().getPlan('plan-1'), isNull); + + final pull = await buildService().performSync(); + expect(pull.isSuccess, isTrue, reason: 'device B pull: ${pull.message}'); + + final restored = await DivePlanRepository().getPlan('plan-1'); + expect( + restored, + isNotNull, + reason: 'plan did not propagate A -> B through the round-trip', + ); + expect(restored!.name, 'Sync plan'); + expect(restored.tanks, hasLength(1)); + expect(restored.segments, hasLength(1)); + }); + }); +} diff --git a/test/features/planner/plan_engine_issues_test.dart b/test/features/planner/plan_engine_issues_test.dart new file mode 100644 index 000000000..e60c58e3c --- /dev/null +++ b/test/features/planner/plan_engine_issues_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/deco_model.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; + +const _air = GasMix(o2: 21); + +domain.DivePlan _airPlan({ + double depth = 45.0, + int minutes = 25, + List? tanks, + double reservePressure = 50.0, +}) { + final tankList = + tanks ?? + const [ + DiveTank(id: 'tank-1', volume: 24.0, startPressure: 232, gasMix: _air), + ]; + final tankId = tankList.first.id; + return domain.DivePlan( + id: 'plan-1', + name: 'Issues test', + gfLow: 40, + gfHigh: 80, + reservePressure: reservePressure, + tanks: tankList, + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: depth, + tankId: tankId, + gasMix: _air, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: depth, + durationMinutes: minutes, + tankId: tankId, + gasMix: _air, + order: 1, + ), + ], + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +Iterable _types(PlanOutcome outcome) => + outcome.issues.map((i) => i.type); + +void main() { + const engine = PlanEngine(); + + group('PlanEngine issues', () { + test('air at 70 m raises critical ppO2', () { + final outcome = engine.compute(_airPlan(depth: 70.0, minutes: 10)); + expect(_types(outcome), contains(PlanIssueType.ppO2Critical)); + expect(outcome.isDiveable, isFalse); + }); + + test('hypoxic mix at the surface raises hypoxicGas', () { + const tx1070 = GasMix(o2: 10, he: 70); + const tank = DiveTank( + id: 'back', + volume: 24, + startPressure: 232, + gasMix: tx1070, + ); + final plan = domain.DivePlan( + id: 'plan-1', + name: 'Hypoxic', + gfLow: 40, + gfHigh: 80, + tanks: const [tank], + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: 60.0, + tankId: 'back', + gasMix: tx1070, + order: 0, + ), + ], + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); + final outcome = engine.compute(plan); + expect(_types(outcome), contains(PlanIssueType.hypoxicGas)); + }); + + test('air at 45 m raises END and critical gas density', () { + final outcome = engine.compute(_airPlan(depth: 45.0)); + expect(_types(outcome), contains(PlanIssueType.endExceeded)); + // python3: air density at 45 m = 6.598 g/L > 6.2 hard limit. + expect(_types(outcome), contains(PlanIssueType.gasDensityCritical)); + }); + + test('tiny tank runs out of gas', () { + final outcome = engine.compute( + _airPlan( + tanks: const [ + DiveTank( + id: 'tank-1', + volume: 3.0, + startPressure: 100, + gasMix: _air, + ), + ], + ), + ); + expect(_types(outcome), contains(PlanIssueType.gasOut)); + expect(outcome.isDiveable, isFalse); + }); + + test('high reserve raises reserve violation without gasOut', () { + final outcome = engine.compute( + _airPlan(depth: 30.0, minutes: 10, reservePressure: 200.0), + ); + expect(_types(outcome), contains(PlanIssueType.gasReserveViolation)); + expect(_types(outcome), isNot(contains(PlanIssueType.gasOut))); + }); + + test('deco without a deco gas raises the alert; adding one clears it', () { + final without = engine.compute(_airPlan()); + expect(_types(without), contains(PlanIssueType.ndlExceededNoDecoGas)); + + final withDeco = engine.compute( + _airPlan( + tanks: const [ + DiveTank( + id: 'tank-1', + volume: 24.0, + startPressure: 232, + gasMix: _air, + ), + DiveTank( + id: 'ean50', + volume: 11.1, + startPressure: 207, + gasMix: GasMix(o2: 50), + role: TankRole.deco, + ), + ], + ), + ); + expect( + _types(withDeco), + isNot(contains(PlanIssueType.ndlExceededNoDecoGas)), + ); + }); + + test('issues are sorted most severe first', () { + final outcome = engine.compute(_airPlan(depth: 70.0, minutes: 20)); + final severities = outcome.issues.map((i) => i.severity.index).toList(); + for (var i = 1; i < severities.length; i++) { + expect(severities[i - 1], greaterThanOrEqualTo(severities[i])); + } + expect(outcome.issues.first.severity, PlanIssueSeverity.critical); + }); + }); + + group('PlanEngine consumption', () { + test('no-deco plan matches hand-computed liters', () { + // 30 m / 10 min air, GF 40/80: no stops. + // descent 100 s at avg 15 m (2.5 bar) on bottom SAC 15: 62.5 L + // bottom 10 min at 4.0 bar on 15: 600 L + // direct ascent 200 s at avg 15 m (2.5 bar) on deco SAC 12: 100 L + // total 762.5 L + final outcome = engine.compute(_airPlan(depth: 30.0, minutes: 10)); + expect(outcome.stops, isEmpty); + expect(outcome.tankUsages.single.litersUsed, closeTo(762.5, 1.0)); + // Compressibility: remaining is BELOW the ideal-gas figure. + const idealRemaining = 232 - 762.5 / 24.0; + expect( + outcome.tankUsages.single.remainingPressure!, + lessThan(idealRemaining + 0.01), + ); + }); + + test('deco stops charge the deco tank, not the back gas', () { + final outcome = engine.compute( + _airPlan( + tanks: const [ + DiveTank( + id: 'back', + volume: 24.0, + startPressure: 232, + gasMix: _air, + ), + DiveTank( + id: 'ean50', + volume: 11.1, + startPressure: 207, + gasMix: GasMix(o2: 50), + role: TankRole.deco, + ), + ], + ), + ); + expect(outcome.stops, isNotEmpty); + final ean50 = outcome.tankUsages.firstWhere((u) => u.tankId == 'ean50'); + expect(ean50.litersUsed, greaterThan(0)); + }); + + test('CNS/OTU accumulate over decompression stops', () { + // 45 m / 25 min air incurs deco; the computed stops must add exposure + // on top of what the bottom segments alone contributed. + final outcome = engine.compute(_airPlan()); + expect(outcome.stops, isNotEmpty); + final bottomCns = outcome.segmentOutcomes.last.cns; + final bottomOtu = outcome.segmentOutcomes.last.otu; + expect(outcome.cnsEnd, greaterThan(bottomCns)); + expect(outcome.otuTotal, greaterThan(bottomOtu)); + }); + + test( + 'a rich deco gas on a long stop adds more CNS than back gas alone', + () { + // Same dive, once with an O2 deco gas carried and once without. The O2 + // stop at 6 m loads CNS fast, so the deco-gas plan ends higher. + final backGasOnly = engine.compute(_airPlan(minutes: 30)); + final withO2 = engine.compute( + _airPlan( + minutes: 30, + tanks: const [ + DiveTank( + id: 'back', + volume: 24, + startPressure: 232, + gasMix: _air, + ), + DiveTank( + id: 'o2', + volume: 11.1, + startPressure: 207, + gasMix: GasMix(o2: 100), + role: TankRole.deco, + ), + ], + ), + ); + expect(withO2.cnsEnd, greaterThan(backGasOnly.cnsEnd)); + }, + ); + + test('compute rejects a non-Buhlmann start state', () { + expect( + () => engine.compute(_airPlan(), startState: const _ForeignState()), + throwsArgumentError, + ); + }); + }); +} + +/// A tissue state the Buhlmann engine cannot consume — used to prove the +/// start-state type guard rejects foreign seeds up front. +class _ForeignState extends TissueState { + const _ForeignState(); +} diff --git a/test/features/planner/plan_engine_schedule_test.dart b/test/features/planner/plan_engine_schedule_test.dart new file mode 100644 index 000000000..ed4cfbf6f --- /dev/null +++ b/test/features/planner/plan_engine_schedule_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/data/services/plan_calculator_service.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; + +const _air = GasMix(o2: 21); +const _airTank = DiveTank( + id: 'tank-1', + volume: 11.1, + startPressure: 207.0, + gasMix: _air, +); + +List _airSegments({double depth = 45.0, int minutes = 25}) => [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: depth, + tankId: 'tank-1', + gasMix: _air, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: depth, + durationMinutes: minutes, + tankId: 'tank-1', + gasMix: _air, + order: 1, + ), +]; + +domain.DivePlan _plan({ + List? segments, + List tanks = const [_airTank], + double lastStopDepth = 3.0, + AirBreakPolicy? airBreaks, +}) { + return domain.DivePlan( + id: 'plan-1', + name: 'Engine test', + gfLow: 40, + gfHigh: 80, + lastStopDepth: lastStopDepth, + airBreaks: airBreaks, + segments: segments ?? _airSegments(), + tanks: tanks, + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +void main() { + group('PlanEngine schedule', () { + test('parity with the legacy PlanCalculatorService', () { + final plan = _plan(); + final outcome = const PlanEngine().compute(plan); + + final legacy = PlanCalculatorService(gfLow: 40, gfHigh: 80).calculatePlan( + segments: plan.segments, + tanks: plan.tanks, + sacRate: 15.0, + ); + + expect(outcome.stops, isNotEmpty); + expect(outcome.stops.length, legacy.decoSchedule.length); + for (var i = 0; i < legacy.decoSchedule.length; i++) { + expect(outcome.stops[i].depthMeters, legacy.decoSchedule[i].depth); + expect( + outcome.stops[i].durationSeconds, + legacy.decoSchedule[i].durationSeconds, + ); + } + expect(outcome.ttsAtBottom, legacy.ttsAtBottom); + expect(outcome.ndlAtBottom, legacy.ndlAtBottom); + }); + + test('trimix multi-gas plan switches to O2 at 6 m and shallower', () { + const backGas = GasMix(o2: 18, he: 45); + const tanks = [ + DiveTank(id: 'back', volume: 24, gasMix: backGas), + DiveTank( + id: 'ean50', + volume: 11.1, + gasMix: GasMix(o2: 50), + role: TankRole.deco, + ), + DiveTank( + id: 'o2', + volume: 11.1, + gasMix: GasMix(o2: 100), + role: TankRole.deco, + ), + ]; + final plan = _plan( + tanks: tanks, + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: 60.0, + tankId: 'back', + gasMix: backGas, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: 60.0, + durationMinutes: 25, + tankId: 'back', + gasMix: backGas, + order: 1, + ), + ], + ); + final outcome = const PlanEngine().compute(plan); + + expect(outcome.stops, isNotEmpty); + for (final stop in outcome.stops.where((s) => s.depthMeters <= 6.0)) { + expect(stop.gasFO2, closeTo(1.0, 1e-9), reason: 'O2 at 6 m and up'); + expect(stop.tankId, 'o2'); + } + // Arrival runtimes strictly increase. + var previous = -1; + for (final stop in outcome.stops) { + expect(stop.arrivalRuntimeSeconds, greaterThan(previous)); + previous = stop.arrivalRuntimeSeconds; + } + }); + + test('air breaks lengthen deco and annotate stops', () { + final baseline = const PlanEngine().compute( + _plan(segments: _airSegments(minutes: 45)), + ); + final withBreaks = const PlanEngine().compute( + _plan( + segments: _airSegments(minutes: 45), + tanks: const [ + _airTank, + DiveTank( + id: 'o2', + volume: 11.1, + gasMix: GasMix(o2: 100), + role: TankRole.deco, + ), + ], + airBreaks: const AirBreakPolicy(o2Seconds: 720, breakSeconds: 360), + ), + ); + // The O2 plan differs from baseline; what matters: annotated breaks + // appear on a long O2 stop. + final o2Stops = withBreaks.stops.where((s) => s.depthMeters <= 6.0); + final totalBreaks = o2Stops.fold( + 0, + (sum, s) => sum + s.airBreakSeconds, + ); + expect(totalBreaks, greaterThan(0)); + expect(baseline.stops.every((s) => s.airBreakSeconds == 0), isTrue); + }); + + test('last stop at 6 m removes the 3 m stop', () { + final outcome = const PlanEngine().compute(_plan(lastStopDepth: 6.0)); + expect(outcome.stops, isNotEmpty); + expect(outcome.stops.every((s) => s.depthMeters >= 6.0), isTrue); + }); + + test('tissue timeline has one increasing entry per segment', () { + final outcome = const PlanEngine().compute(_plan()); + expect(outcome.tissueTimeline, hasLength(2)); + expect( + outcome.tissueTimeline[0].$1, + lessThan(outcome.tissueTimeline[1].$1), + ); + expect(outcome.segmentOutcomes, hasLength(2)); + expect(outcome.segmentOutcomes.last.inDeco, isTrue); + }); + }); +} diff --git a/test/features/planner/plan_outcome_entity_test.dart b/test/features/planner/plan_outcome_entity_test.dart new file mode 100644 index 000000000..749dc8803 --- /dev/null +++ b/test/features/planner/plan_outcome_entity_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/deco/deco_model.dart'; +import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; + +void main() { + group('PlanIssue', () { + test('equality tracks all props', () { + const a = PlanIssue( + type: PlanIssueType.ppO2High, + severity: PlanIssueSeverity.warning, + message: 'high', + atRuntime: 120, + atDepth: 40, + segmentId: 's1', + value: 1.5, + threshold: 1.4, + ); + const b = PlanIssue( + type: PlanIssueType.ppO2High, + severity: PlanIssueSeverity.warning, + message: 'high', + atRuntime: 120, + atDepth: 40, + segmentId: 's1', + value: 1.5, + threshold: 1.4, + ); + expect(a, equals(b)); + expect(a.props, hasLength(8)); + expect( + a, + isNot( + equals( + const PlanIssue( + type: PlanIssueType.gasOut, + severity: PlanIssueSeverity.critical, + message: 'out', + ), + ), + ), + ); + }); + }); + + group('PlanStop', () { + test('carries gas and arrival, equality tracks props', () { + const stop = PlanStop( + depthMeters: 6, + durationSeconds: 600, + airBreakSeconds: 120, + gasFO2: 1.0, + gasFHe: 0.0, + tankId: 't-o2', + arrivalRuntimeSeconds: 1800, + ); + expect(stop.props, hasLength(7)); + expect( + stop, + equals( + const PlanStop( + depthMeters: 6, + durationSeconds: 600, + airBreakSeconds: 120, + gasFO2: 1.0, + gasFHe: 0.0, + tankId: 't-o2', + arrivalRuntimeSeconds: 1800, + ), + ), + ); + }); + }); + + group('SegmentOutcome', () { + test('inDeco is true only for a negative NDL', () { + SegmentOutcome make(int ndl) => SegmentOutcome( + segmentId: 's1', + startRuntime: 0, + endRuntime: 600, + ndlAtEnd: ndl, + ceilingAtEnd: 0, + ttsAtEnd: 0, + cns: 1, + otu: 1, + maxPpO2: 1.2, + ); + expect(make(-1).inDeco, isTrue); + expect(make(300).inDeco, isFalse); + expect(make(300).props, hasLength(9)); + }); + }); + + group('PlanTankUsage', () { + test('defaults and equality', () { + const usage = PlanTankUsage( + tankId: 't1', + litersUsed: 1200, + percentUsed: 60, + ); + expect(usage.remainingPressure, isNull); + expect(usage.reserveViolation, isFalse); + expect(usage.props, hasLength(5)); + }); + }); + + group('PlanOutcome', () { + PlanOutcome make(List issues, List stops) { + return PlanOutcome( + runtimeSeconds: 3000, + maxDepth: 45, + ndlAtBottom: -1, + ttsAtBottom: 1200, + stops: stops, + segmentOutcomes: const [], + tankUsages: const [], + cnsEnd: 20, + otuTotal: 30, + issues: issues, + endTissue: const BuhlmannState(compartments: []), + tissueTimeline: const [], + ); + } + + test('isDiveable is false when a critical issue is present', () { + final diveable = make(const [ + PlanIssue( + type: PlanIssueType.ppO2High, + severity: PlanIssueSeverity.warning, + message: 'w', + ), + ], const []); + expect(diveable.isDiveable, isTrue); + + final blocked = make(const [ + PlanIssue( + type: PlanIssueType.gasOut, + severity: PlanIssueSeverity.critical, + message: 'c', + ), + ], const []); + expect(blocked.isDiveable, isFalse); + }); + + test('totalDecoSeconds sums stop durations', () { + final outcome = make(const [], const [ + PlanStop( + depthMeters: 6, + durationSeconds: 600, + gasFO2: 1.0, + gasFHe: 0.0, + arrivalRuntimeSeconds: 1500, + ), + PlanStop( + depthMeters: 3, + durationSeconds: 300, + gasFO2: 1.0, + gasFHe: 0.0, + arrivalRuntimeSeconds: 2100, + ), + ]); + expect(outcome.totalDecoSeconds, 900); + }); + }); +}