From b0fc5f5895e8cb7bdf07f3683b3a9c2ffeef9f7f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:47:52 -0400 Subject: [PATCH 1/8] docs: add dive planner phase 6 (log integration) plan --- .../2026-07-05-dive-planner-phase6-log.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-dive-planner-phase6-log.md diff --git a/docs/superpowers/plans/2026-07-05-dive-planner-phase6-log.md b/docs/superpowers/plans/2026-07-05-dive-planner-phase6-log.md new file mode 100644 index 000000000..c5630960d --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dive-planner-phase6-log.md @@ -0,0 +1,135 @@ +# Dive Planner Phase 6 — Log Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans +> to implement this plan task-by-task. + +**Goal:** Wire the planner to the dive log: seed tissues from a logged dive, +auto-fill SAC from logged averages, persist convert-to-dive with a back-link, +and overlay the planned profile on the dive detail chart. + +**Architecture:** The engine already accepts `startState` on +`PlanEngine.compute`; this phase feeds it. Followed-dive context lives on +`DivePlanState` (sourceDiveId + initialTissueState + surfaceInterval) and maps +into the `domain.DivePlan` aggregate. Convert-to-dive persists through the +existing `DiveRepository.createDive` and records `linkedDiveId` on the plan; +the dive detail page looks the plan back up by `linkedDiveId` and renders it +as a `ChartSourceOverlay`. + +**Tech Stack:** Flutter, Riverpod, Drift, existing deco engine (`BuhlmannGf`). + +## Global Constraints + +- All deco math flows through the engine already used by dive details. +- Units respect diver settings via `UnitFormatter`. +- New user-facing strings go in `app_en.arb` + all 10 other locales, + alphabetically, then `flutter gen-l10n`. +- `dart format .` clean; whole-project `flutter analyze` clean. +- Commit per task on `worktree-dive-planner-phase6-log`. + +--- + +### Task 1: Followed-dive context on state, notifier, and mapper + +**Files:** +- Modify: `lib/features/dive_planner/domain/entities/plan_result.dart` +- Modify: `lib/features/dive_planner/presentation/providers/dive_planner_providers.dart` +- Modify: `lib/features/planner/domain/services/dive_plan_state_mapper.dart` +- Test: `test/features/planner/dive_plan_state_mapper_test.dart` + +**Steps:** +- [ ] Add `sourceDiveId` and `linkedDiveId` (`String?`, with + `clearSourceDiveId`/`clearLinkedDiveId` copyWith flags) to `DivePlanState`. +- [ ] Notifier: replace stub `loadTissueFromDive` with + `setFollowedDive({required String diveId, List? compartments, required Duration surfaceInterval})` + (sets sourceDiveId + initialTissueState + surfaceInterval, marks dirty), + add `clearFollowedDive()` (clears all three) and `setLinkedDive(String? diveId)`. +- [ ] `stateFromDivePlan` copies sourceDiveId/linkedDiveId out of the plan; + `divePlanFromState` writes them from the state (state-owned now — update the + preservation test which expects `sourceDiveId` to survive from `existing`). +- [ ] Tests: round-trip both ids; `setFollowedDive`/`clearFollowedDive` + behavior. Run mapper + planner provider tests. Commit. + +### Task 2: Tissue seeding into the live outcome + +**Files:** +- Modify: `lib/features/planner/presentation/providers/plan_canvas_providers.dart` +- Modify (if needed): `lib/core/deco/deco_model.dart` +- Test: `test/features/planner/plan_engine_seeding_test.dart` + +**Steps:** +- [ ] Helper `TissueState? seededStartState(DivePlanState state, {required double gfLow, required double gfHigh})`: + null when `initialTissueState` is null; otherwise off-gas the compartments + at surface for `surfaceInterval` (air) via `BuhlmannAlgorithm` and return + `BuhlmannGf(...).restoreState(...)` — mirroring + `_computeResidualTissueState` in `profile_analysis_provider.dart`. +- [ ] `planOutcomeProvider` passes the seeded state to `engine.compute`. +- [ ] Engine-level test: identical deco plan computed fresh vs seeded with + loaded compartments → seeded plan has strictly more `totalDecoSeconds`; + a long surface interval (>12 h) trends back toward the fresh plan. Commit. + +### Task 3: Follow-a-dive picker, Following chip, SAC auto-fill + +**Files:** +- Create: `lib/features/planner/presentation/widgets/follow_dive_sheet.dart` +- Modify: `lib/features/planner/presentation/pages/plan_canvas_page.dart` +- Modify: `lib/features/dive_planner/presentation/widgets/plan_settings_panel.dart` +- Modify: `lib/features/planner/presentation/providers/plan_canvas_providers.dart` +- Test: `test/features/planner/follow_dive_test.dart` + +**Steps:** +- [ ] `loggedAverageSacProvider` (`FutureProvider`): statistics + repository `getSacVolumeByTankRole()`, return the `'backGas'` entry. +- [ ] `followDiveSheet`: recent dives (via `divesProvider`, first ~30) as + ListTiles (name, date, depth/duration via UnitFormatter). On tap: read + `profileAnalysisProvider(dive.id).future`, take + `decoStatuses.last.compartments` (null-safe), surface interval = + `now - dive end` clamped to ≥ 10 min (fallback 60 min when unknown), call + `setFollowedDive`, pop. +- [ ] Canvas page: menu entry "Follow a dive"; when `sourceDiveId != null` + show a Following chip next to the status chips with a clear (×) action. +- [ ] Settings panel: "Use logged average" affordance next to the SAC field + that fills from `loggedAverageSacProvider` when available. +- [ ] Widget test: picker sets state; chip shows and clears. Commit. + +### Task 4: Convert-to-dive persistence + +**Files:** +- Modify: `lib/features/dive_planner/presentation/providers/dive_planner_providers.dart` +- Modify: `lib/features/planner/presentation/pages/plan_canvas_page.dart` +- Test: `test/features/planner/convert_to_dive_test.dart` + +**Steps:** +- [ ] `toDive()` gets a fresh UUID (not the plan id), `isPlanned: true`, + dive name from plan name. +- [ ] `_convertToDive`: confirm dialog → `DiveRepository.createDive` (via the + same notifier/provider path other creators use so sync + list refresh + happen), `setLinkedDive(dive.id)`, `save()`, snackbar with a View action + navigating to `/dives/{id}`. +- [ ] Test: converting persists a dive with `isPlanned` and links the plan. + Commit. + +### Task 5: Plan-vs-actual overlay on dive detail + +**Files:** +- Modify: `lib/features/planner/data/repositories/dive_plan_repository.dart` +- Create: `lib/features/planner/presentation/providers/plan_overlay_provider.dart` +- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart` +- Test: `test/features/planner/plan_overlay_test.dart` + +**Steps:** +- [ ] Repository: `getPlanByLinkedDiveId(String diveId)` → + `domain.DivePlan?` (query dive_plans where linked_dive_id = ?). +- [ ] `plannedProfileOverlayProvider` (`FutureProvider.family`): + load plan, run `PlanEngine`, `buildCanvasSeries`, map profile points → + `DiveProfilePoint`; label from l10n, distinct color, `computerId: null`. +- [ ] Dive detail: watch the provider, append to `overlays` when non-null. +- [ ] Test: repository lookup + overlay point mapping. Commit. + +### Task 6: l10n + verification sweep + PR + +**Steps:** +- [ ] New keys (all locales, alphabetical, `flutter gen-l10n`). +- [ ] `dart format .`; whole-project `flutter analyze`; run + `test/features/planner/` + touched dive_log tests. +- [ ] Commit, push `-u origin worktree-dive-planner-phase6-log --no-verify` + (with `env -u GITHUB_TOKEN`), PR stacked on the Phase 5 branch. From e8e0bdf97bd8cd88ad651b75c44759f696e2b162 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:51:49 -0400 Subject: [PATCH 2/8] feat(planner): carry followed-dive and linked-dive context on plan state --- .../domain/entities/plan_result.dart | 20 ++++++++ .../providers/dive_planner_providers.dart | 45 ++++++++++++++---- .../services/dive_plan_state_mapper.dart | 13 +++-- .../dive_planner_providers_test.dart | 47 +++++++++++++++++++ .../planner/dive_plan_state_mapper_test.dart | 28 +++++++++-- 5 files changed, 139 insertions(+), 14 deletions(-) diff --git a/lib/features/dive_planner/domain/entities/plan_result.dart b/lib/features/dive_planner/domain/entities/plan_result.dart index 1b4cf6aee..0b23e2b7a 100644 --- a/lib/features/dive_planner/domain/entities/plan_result.dart +++ b/lib/features/dive_planner/domain/entities/plan_result.dart @@ -493,6 +493,12 @@ class DivePlanState extends Equatable { /// Initial tissue state from previous dive. final List? initialTissueState; + /// Logged dive this plan follows (tissue seeding source). + final String? sourceDiveId; + + /// Dive created from this plan via convert-to-dive. + final String? linkedDiveId; + /// Dive site for the plan. final String? siteId; @@ -542,6 +548,8 @@ class DivePlanState extends Equatable { this.sacRate = 15.0, this.surfaceInterval, this.initialTissueState, + this.sourceDiveId, + this.linkedDiveId, this.siteId, this.altitude, this.mode = PlanMode.oc, @@ -597,6 +605,8 @@ class DivePlanState extends Equatable { double? sacRate, Duration? surfaceInterval, List? initialTissueState, + String? sourceDiveId, + String? linkedDiveId, String? siteId, double? altitude, PlanMode? mode, @@ -615,6 +625,8 @@ class DivePlanState extends Equatable { DateTime? updatedAt, bool clearSurfaceInterval = false, bool clearInitialTissueState = false, + bool clearSourceDiveId = false, + bool clearLinkedDiveId = false, bool clearSiteId = false, bool clearAltitude = false, bool clearSetpoints = false, @@ -633,6 +645,12 @@ class DivePlanState extends Equatable { initialTissueState: clearInitialTissueState ? null : (initialTissueState ?? this.initialTissueState), + sourceDiveId: clearSourceDiveId + ? null + : (sourceDiveId ?? this.sourceDiveId), + linkedDiveId: clearLinkedDiveId + ? null + : (linkedDiveId ?? this.linkedDiveId), siteId: clearSiteId ? null : (siteId ?? this.siteId), altitude: clearAltitude ? null : (altitude ?? this.altitude), mode: mode ?? this.mode, @@ -668,6 +686,8 @@ class DivePlanState extends Equatable { sacRate, surfaceInterval, initialTissueState, + sourceDiveId, + linkedDiveId, siteId, altitude, mode, 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 8893a8022..8da15db81 100644 --- a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart +++ b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart @@ -3,6 +3,7 @@ import 'package:uuid/uuid.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/deco/entities/dive_environment.dart'; +import 'package:submersion/core/deco/entities/tissue_compartment.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -394,14 +395,42 @@ class DivePlanNotifier extends StateNotifier { ); } - /// Load tissue state from a previous dive (for repetitive planning). - void loadTissueFromDive(Dive previousDive) { - // This would require accessing the profile analysis to get end tissue state - // For now, we'll just set the surface interval - if (previousDive.dateTime.isBefore(DateTime.now())) { - final interval = DateTime.now().difference(previousDive.dateTime); - setSurfaceInterval(interval); - } + /// Follow a logged dive: seed tissues from its end-of-dive compartments + /// and set the surface interval separating it from this plan. + void setFollowedDive({ + required String diveId, + List? compartments, + required Duration surfaceInterval, + }) { + state = state.copyWith( + sourceDiveId: diveId, + initialTissueState: compartments, + clearInitialTissueState: compartments == null, + surfaceInterval: surfaceInterval, + isDirty: true, + updatedAt: DateTime.now(), + ); + } + + /// Stop following a dive: drop the seed tissues and surface interval. + void clearFollowedDive() { + state = state.copyWith( + clearSourceDiveId: true, + clearSurfaceInterval: true, + clearInitialTissueState: true, + isDirty: true, + updatedAt: DateTime.now(), + ); + } + + /// Record (or clear) the dive created from this plan via convert-to-dive. + void setLinkedDive(String? diveId) { + state = state.copyWith( + linkedDiveId: diveId, + clearLinkedDiveId: diveId == null, + isDirty: true, + updatedAt: DateTime.now(), + ); } /// Clear repetitive dive settings. diff --git a/lib/features/planner/domain/services/dive_plan_state_mapper.dart b/lib/features/planner/domain/services/dive_plan_state_mapper.dart index d3c9ae553..30b868f6a 100644 --- a/lib/features/planner/domain/services/dive_plan_state_mapper.dart +++ b/lib/features/planner/domain/services/dive_plan_state_mapper.dart @@ -6,9 +6,10 @@ import 'package:submersion/features/planner/domain/entities/dive_plan.dart' /// [domain.DivePlan] aggregate. /// /// The UI state carries a subset of the aggregate; [existing] preserves -/// fields the state does not know about (rates, contingency config, water -/// type, dive links) across an edit-save cycle so a plan touched by the UI -/// does not lose them. Mode and setpoints travel WITH the state. +/// fields the state does not know about (rates, water type, air breaks) +/// across an edit-save cycle so a plan touched by the UI does not lose +/// them. Mode, setpoints, contingency config, and dive links travel WITH +/// the state. domain.DivePlan divePlanFromState( DivePlanState state, { domain.DivePlan? existing, @@ -44,6 +45,10 @@ domain.DivePlan divePlanFromState( clearTurnPressureRule: state.turnPressureRule == null, turnPressureFraction: state.turnPressureFraction, clearTurnPressureFraction: state.turnPressureFraction == null, + sourceDiveId: state.sourceDiveId, + clearSourceDiveId: state.sourceDiveId == null, + linkedDiveId: state.linkedDiveId, + clearLinkedDiveId: state.linkedDiveId == null, gfLow: state.gfLow, gfHigh: state.gfHigh, sacBottom: state.sacRate, @@ -73,6 +78,8 @@ DivePlanState stateFromDivePlan(domain.DivePlan plan) { deviationTimeMinutes: plan.deviationTimeMinutes, turnPressureRule: plan.turnPressureRule, turnPressureFraction: plan.turnPressureFraction, + sourceDiveId: plan.sourceDiveId, + linkedDiveId: plan.linkedDiveId, gfLow: plan.gfLow, gfHigh: plan.gfHigh, sacRate: plan.sacBottom, diff --git a/test/features/dive_planner/presentation/providers/dive_planner_providers_test.dart b/test/features/dive_planner/presentation/providers/dive_planner_providers_test.dart index eec42c9cc..d64b87a59 100644 --- a/test/features/dive_planner/presentation/providers/dive_planner_providers_test.dart +++ b/test/features/dive_planner/presentation/providers/dive_planner_providers_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/constants/map_style.dart'; import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/deco/entities/tissue_compartment.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_planner/data/services/plan_calculator_service.dart'; import 'package:submersion/features/dive_planner/domain/entities/plan_result.dart'; @@ -125,5 +126,51 @@ void main() { notifier.newPlan(); expect(notifier.state.reservePressure, 40); }); + + test('setFollowedDive seeds context and clearFollowedDive drops it', () { + final notifier = DivePlanNotifier(PlanCalculatorService()); + addTearDown(notifier.dispose); + + final compartments = [ + const TissueCompartment( + compartmentNumber: 1, + halfTimeN2: 5.0, + halfTimeHe: 1.88, + mValueAN2: 1.1696, + mValueBN2: 0.5578, + mValueAHe: 1.6189, + mValueBHe: 0.4770, + currentPN2: 1.2, + ), + ]; + notifier.setFollowedDive( + diveId: 'dive-9', + compartments: compartments, + surfaceInterval: const Duration(hours: 2), + ); + + var state = notifier.state; + expect(state.sourceDiveId, 'dive-9'); + expect(state.initialTissueState, compartments); + expect(state.surfaceInterval, const Duration(hours: 2)); + expect(state.isDirty, isTrue); + + notifier.clearFollowedDive(); + state = notifier.state; + expect(state.sourceDiveId, isNull); + expect(state.initialTissueState, isNull); + expect(state.surfaceInterval, isNull); + }); + + test('setLinkedDive records and clears the converted dive', () { + final notifier = DivePlanNotifier(PlanCalculatorService()); + addTearDown(notifier.dispose); + + notifier.setLinkedDive('dive-12'); + expect(notifier.state.linkedDiveId, 'dive-12'); + + notifier.setLinkedDive(null); + expect(notifier.state.linkedDiveId, isNull); + }); }); } diff --git a/test/features/planner/dive_plan_state_mapper_test.dart b/test/features/planner/dive_plan_state_mapper_test.dart index 9639bfad0..6396839d5 100644 --- a/test/features/planner/dive_plan_state_mapper_test.dart +++ b/test/features/planner/dive_plan_state_mapper_test.dart @@ -78,19 +78,41 @@ void main() { final merged = divePlanFromState(state(), existing: existing); // State-owned fields come from the state (mode/setpoints joined that - // set in Phase 4, contingency config in Phase 5: the state defaults to - // OC / null setpoints / no turn rule)... + // set in Phase 4, contingency config in Phase 5, dive links in Phase 6: + // the state defaults to OC / null setpoints / no turn rule / no links)... expect(merged.name, 'Reef dive'); expect(merged.gfLow, 45); expect(merged.sacBottom, 17.0); expect(merged.mode, domain.PlanMode.oc); expect(merged.setpointHigh, isNull); expect(merged.turnPressureRule, isNull); + expect(merged.sourceDiveId, isNull); // ...while aggregate-only fields survive the cycle. expect(merged.waterType, WaterType.fresh); expect(merged.descentRate, 20.0); expect(merged.airBreaks, isNotNull); - expect(merged.sourceDiveId, 'dive-9'); + }); + + test('dive links round-trip through the state', () { + final linkedState = state().copyWith( + sourceDiveId: 'dive-9', + linkedDiveId: 'dive-12', + ); + final plan = divePlanFromState(linkedState); + expect(plan.sourceDiveId, 'dive-9'); + expect(plan.linkedDiveId, 'dive-12'); + + final restored = stateFromDivePlan(plan); + expect(restored.sourceDiveId, 'dive-9'); + expect(restored.linkedDiveId, 'dive-12'); + + final cleared = restored.copyWith( + clearSourceDiveId: true, + clearLinkedDiveId: true, + ); + final unlinked = divePlanFromState(cleared, existing: plan); + expect(unlinked.sourceDiveId, isNull); + expect(unlinked.linkedDiveId, isNull); }); test('mode and setpoints round-trip through the state', () { From 5007d3641ea20299fb090a37a27a0ad7cbaa5ed5 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:54:37 -0400 Subject: [PATCH 3/8] feat(planner): seed plan outcome tissues from a followed dive --- .../planner/domain/services/tissue_seed.dart | 47 +++++++ .../providers/plan_canvas_providers.dart | 11 +- .../planner/plan_engine_seeding_test.dart | 119 ++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 lib/features/planner/domain/services/tissue_seed.dart create mode 100644 test/features/planner/plan_engine_seeding_test.dart diff --git a/lib/features/planner/domain/services/tissue_seed.dart b/lib/features/planner/domain/services/tissue_seed.dart new file mode 100644 index 000000000..fe7660f67 --- /dev/null +++ b/lib/features/planner/domain/services/tissue_seed.dart @@ -0,0 +1,47 @@ +import 'package:submersion/core/deco/buhlmann_algorithm.dart'; +import 'package:submersion/core/deco/constants/buhlmann_coefficients.dart'; +import 'package:submersion/core/deco/deco_model.dart'; +import 'package:submersion/core/deco/entities/dive_environment.dart'; +import 'package:submersion/core/deco/entities/tissue_compartment.dart'; + +/// Builds the [TissueState] a plan should start from when it follows a +/// logged dive: the dive's end-of-dive [compartments], off-gassed at the +/// surface (breathing air) for [surfaceInterval]. +/// +/// Mirrors the residual-tissue lookback the dive details page performs in +/// profile_analysis_provider so a plan seeded from a dive agrees with the +/// log's own repetitive-dive math. The GF-low ceiling anchor is re-derived +/// from the seeded loading, as a fresh dive would. +/// +/// Returns null when [compartments] is null (nothing to seed). +TissueState? seededTissueState({ + required List? compartments, + required Duration? surfaceInterval, + required double gfLow, + required double gfHigh, + DiveEnvironment environment = DiveEnvironment.standard, +}) { + if (compartments == null || compartments.isEmpty) return null; + + final algorithm = BuhlmannAlgorithm( + gfLow: gfLow, + gfHigh: gfHigh, + environment: environment, + ); + algorithm.setCompartments(List.from(compartments)); + + final intervalSeconds = surfaceInterval?.inSeconds ?? 0; + if (intervalSeconds > 0) { + algorithm.calculateSegment( + depthMeters: 0, + durationSeconds: intervalSeconds, + fN2: airN2Fraction, + fHe: 0.0, + ); + } + + return BuhlmannState( + compartments: algorithm.compartments, + gfLowCeilingAnchor: algorithm.gfLowCeilingAnchor, + ); +} diff --git a/lib/features/planner/presentation/providers/plan_canvas_providers.dart b/lib/features/planner/presentation/providers/plan_canvas_providers.dart index 6cf4fbc13..3655f57d6 100644 --- a/lib/features/planner/presentation/providers/plan_canvas_providers.dart +++ b/lib/features/planner/presentation/providers/plan_canvas_providers.dart @@ -1,3 +1,4 @@ +import 'package:submersion/core/deco/entities/dive_environment.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; @@ -7,6 +8,7 @@ import 'package:submersion/features/planner/domain/services/bailout_solver.dart' import 'package:submersion/features/planner/domain/services/contingency_service.dart'; import 'package:submersion/features/planner/domain/services/dive_plan_state_mapper.dart'; import 'package:submersion/features/planner/domain/services/plan_engine.dart'; +import 'package:submersion/features/planner/domain/services/tissue_seed.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; /// PlanEngine thresholds sourced from the diver's deco settings. @@ -24,7 +26,14 @@ final planEngineConfigProvider = Provider((ref) { final planOutcomeProvider = Provider((ref) { final state = ref.watch(divePlanNotifierProvider); final engine = PlanEngine(config: ref.watch(planEngineConfigProvider)); - return engine.compute(divePlanFromState(state)); + final startState = seededTissueState( + compartments: state.initialTissueState, + surfaceInterval: state.surfaceInterval, + gfLow: state.gfLow / 100.0, + gfHigh: state.gfHigh / 100.0, + environment: DiveEnvironment.forConditions(altitudeMeters: state.altitude), + ); + return engine.compute(divePlanFromState(state), startState: startState); }); /// Scrub cursor position along the plan, in seconds (null = not scrubbing). diff --git a/test/features/planner/plan_engine_seeding_test.dart b/test/features/planner/plan_engine_seeding_test.dart new file mode 100644 index 000000000..003f11a87 --- /dev/null +++ b/test/features/planner/plan_engine_seeding_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/deco/buhlmann_algorithm.dart'; +import 'package:submersion/core/deco/entities/tissue_compartment.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/services/plan_engine.dart'; +import 'package:submersion/features/planner/domain/services/tissue_seed.dart'; + +const _air = GasMix(o2: 21); +const _airTank = DiveTank( + id: 'tank-1', + volume: 11.1, + startPressure: 207.0, + gasMix: _air, +); + +domain.DivePlan _plan() { + return domain.DivePlan( + id: 'plan-1', + name: 'Seeding test', + gfLow: 40, + gfHigh: 80, + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: 40.0, + tankId: 'tank-1', + gasMix: _air, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: 40.0, + durationMinutes: 20, + tankId: 'tank-1', + gasMix: _air, + order: 1, + ), + ], + tanks: const [_airTank], + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +/// End-of-dive compartments from a heavy prior dive (40 m for 35 min on air). +List _priorDiveCompartments() { + final algorithm = BuhlmannAlgorithm(gfLow: 0.40, gfHigh: 0.80); + algorithm.calculateSegment(depthMeters: 40.0, durationSeconds: 35 * 60); + return algorithm.compartments; +} + +void main() { + group('tissue seeding', () { + test('null compartments produce no seed', () { + expect( + seededTissueState( + compartments: null, + surfaceInterval: const Duration(hours: 1), + gfLow: 0.40, + gfHigh: 0.80, + ), + isNull, + ); + }); + + test('a seeded repetitive plan carries more deco than a fresh one', () { + const engine = PlanEngine(); + final fresh = engine.compute(_plan()); + + final seeded = seededTissueState( + compartments: _priorDiveCompartments(), + surfaceInterval: const Duration(hours: 1), + gfLow: 0.40, + gfHigh: 0.80, + ); + final repetitive = engine.compute(_plan(), startState: seeded); + + expect(repetitive.totalDecoSeconds, greaterThan(fresh.totalDecoSeconds)); + }); + + test('a long surface interval trends back toward the fresh plan', () { + const engine = PlanEngine(); + final fresh = engine.compute(_plan()); + + final compartments = _priorDiveCompartments(); + final shortInterval = engine.compute( + _plan(), + startState: seededTissueState( + compartments: compartments, + surfaceInterval: const Duration(hours: 1), + gfLow: 0.40, + gfHigh: 0.80, + ), + ); + final longInterval = engine.compute( + _plan(), + startState: seededTissueState( + compartments: compartments, + surfaceInterval: const Duration(hours: 24), + gfLow: 0.40, + gfHigh: 0.80, + ), + ); + + expect( + longInterval.totalDecoSeconds, + lessThan(shortInterval.totalDecoSeconds), + ); + // After a day at the surface the tissues are essentially clean again. + expect( + (longInterval.totalDecoSeconds - fresh.totalDecoSeconds).abs(), + lessThanOrEqualTo(60), + ); + }); + }); +} From c235cd2643f787aa5f07438b68f6f669607e56cf Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:59:46 -0400 Subject: [PATCH 4/8] feat(planner): follow-a-dive picker with tissue seeding and logged SAC auto-fill --- .../widgets/plan_settings_panel.dart | 33 ++++ .../presentation/pages/plan_canvas_page.dart | 8 + .../providers/plan_canvas_providers.dart | 10 ++ .../widgets/follow_dive_sheet.dart | 150 ++++++++++++++++++ .../widgets/plan_status_chips.dart | 30 ++++ lib/l10n/arb/app_en.arb | 18 +++ lib/l10n/arb/app_localizations.dart | 48 ++++++ lib/l10n/arb/app_localizations_ar.dart | 29 ++++ lib/l10n/arb/app_localizations_de.dart | 29 ++++ lib/l10n/arb/app_localizations_en.dart | 29 ++++ lib/l10n/arb/app_localizations_es.dart | 29 ++++ lib/l10n/arb/app_localizations_fr.dart | 29 ++++ lib/l10n/arb/app_localizations_he.dart | 29 ++++ lib/l10n/arb/app_localizations_hu.dart | 29 ++++ lib/l10n/arb/app_localizations_it.dart | 29 ++++ lib/l10n/arb/app_localizations_nl.dart | 29 ++++ lib/l10n/arb/app_localizations_pt.dart | 29 ++++ lib/l10n/arb/app_localizations_zh.dart | 29 ++++ test/features/planner/follow_dive_test.dart | 102 ++++++++++++ 19 files changed, 718 insertions(+) create mode 100644 lib/features/planner/presentation/widgets/follow_dive_sheet.dart create mode 100644 test/features/planner/follow_dive_test.dart diff --git a/lib/features/dive_planner/presentation/widgets/plan_settings_panel.dart b/lib/features/dive_planner/presentation/widgets/plan_settings_panel.dart index cc0a65812..b51a6c557 100644 --- a/lib/features/dive_planner/presentation/widgets/plan_settings_panel.dart +++ b/lib/features/dive_planner/presentation/widgets/plan_settings_panel.dart @@ -8,6 +8,7 @@ import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/dive_planner/domain/entities/plan_result.dart'; import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// Panel for configuring dive plan settings (GF, SAC, site). @@ -110,6 +111,7 @@ class PlanSettingsPanel extends ConsumerWidget { ), ], ), + _LoggedSacButton(currentSac: planState.sacRate, units: units), const SizedBox(height: 16), // Altitude and reserve pressure row @@ -162,6 +164,37 @@ class PlanSettingsPanel extends ConsumerWidget { } } +/// One-tap SAC auto-fill from the diver's logged average ("from your log"). +/// Hidden when no logged average exists or it already matches the plan. +class _LoggedSacButton extends ConsumerWidget { + const _LoggedSacButton({required this.currentSac, required this.units}); + + final double currentSac; + final UnitFormatter units; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final loggedSac = ref.watch(loggedAverageSacProvider).valueOrNull; + if (loggedSac == null || (loggedSac - currentSac).abs() < 0.5) { + return const SizedBox.shrink(); + } + + final display = + '${units.convertVolume(loggedSac).toStringAsFixed(1)} ' + '${units.volumeSymbol}/min'; + return Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + icon: const Icon(Icons.history, size: 18), + label: Text(context.l10n.plannerCanvas_sac_useLogged(display)), + onPressed: () => ref + .read(divePlanNotifierProvider.notifier) + .updateSacRate(loggedSac.clamp(8.0, 30.0)), + ), + ); + } +} + /// Altitude input with group indicator for altitude diving. class _AltitudeInput extends StatefulWidget { final double? altitude; diff --git a/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart index 92397f10e..a0e7a498b 100644 --- a/lib/features/planner/presentation/pages/plan_canvas_page.dart +++ b/lib/features/planner/presentation/pages/plan_canvas_page.dart @@ -13,6 +13,7 @@ import 'package:submersion/features/planner/presentation/providers/plan_canvas_p import 'package:submersion/features/planner/presentation/widgets/ccr_settings_section.dart'; import 'package:submersion/features/planner/presentation/widgets/contingency_chips.dart'; import 'package:submersion/features/planner/presentation/widgets/contingency_settings_section.dart'; +import 'package:submersion/features/planner/presentation/widgets/follow_dive_sheet.dart'; import 'package:submersion/features/planner/presentation/widgets/plan_canvas_chart.dart'; import 'package:submersion/features/planner/presentation/widgets/plan_results_sheet.dart'; import 'package:submersion/features/planner/presentation/widgets/plan_status_chips.dart'; @@ -109,6 +110,11 @@ class _PlanCanvasPageState extends ConsumerState { Icons.folder_open, context.l10n.plannerCanvas_saved_title, ), + _menuItem( + 'follow', + Icons.history, + context.l10n.plannerCanvas_follow_title, + ), _menuItem( 'settings', Icons.tune, @@ -152,6 +158,8 @@ class _PlanCanvasPageState extends ConsumerState { ); case 'saved': showSavedPlansSheet(context); + case 'follow': + showFollowDiveSheet(context); case 'settings': _showSettingsSheet(context); case 'convert': diff --git a/lib/features/planner/presentation/providers/plan_canvas_providers.dart b/lib/features/planner/presentation/providers/plan_canvas_providers.dart index 3655f57d6..487a9f1fe 100644 --- a/lib/features/planner/presentation/providers/plan_canvas_providers.dart +++ b/lib/features/planner/presentation/providers/plan_canvas_providers.dart @@ -10,6 +10,7 @@ import 'package:submersion/features/planner/domain/services/dive_plan_state_mapp import 'package:submersion/features/planner/domain/services/plan_engine.dart'; import 'package:submersion/features/planner/domain/services/tissue_seed.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; /// PlanEngine thresholds sourced from the diver's deco settings. final planEngineConfigProvider = Provider((ref) { @@ -36,6 +37,15 @@ final planOutcomeProvider = Provider((ref) { return engine.compute(divePlanFromState(state), startState: startState); }); +/// The diver's logged average back-gas SAC in L/min ("from your log"); +/// null when no logged dive carries enough tank data to compute one. +final loggedAverageSacProvider = FutureProvider((ref) async { + final repository = ref.watch(statisticsRepositoryProvider); + final sacByRole = await repository.getSacVolumeByTankRole(); + return sacByRole['backGas'] ?? + (sacByRole.isEmpty ? null : sacByRole.values.first); +}); + /// Scrub cursor position along the plan, in seconds (null = not scrubbing). final scrubTimeProvider = StateProvider((_) => null); diff --git a/lib/features/planner/presentation/widgets/follow_dive_sheet.dart b/lib/features/planner/presentation/widgets/follow_dive_sheet.dart new file mode 100644 index 000000000..a986dabdb --- /dev/null +++ b/lib/features/planner/presentation/widgets/follow_dive_sheet.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/profile_analysis_provider.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Present the follow-a-dive picker as a modal bottom sheet. +Future showFollowDiveSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => const FollowDiveSheet(), + ); +} + +/// Picks a logged dive for the plan to follow: seeds the plan's start +/// tissues from the dive's end-of-dive compartment state (same analysis +/// the dive details page runs) and sets the surface interval. +class FollowDiveSheet extends ConsumerStatefulWidget { + const FollowDiveSheet({super.key, this.maxDives = 30}); + + /// How many recent dives to offer. + final int maxDives; + + @override + ConsumerState createState() => _FollowDiveSheetState(); +} + +class _FollowDiveSheetState extends ConsumerState { + String? _loadingDiveId; + + @override + Widget build(BuildContext context) { + final divesAsync = ref.watch(divesProvider); + final theme = Theme.of(context); + final units = UnitFormatter(ref.watch(settingsProvider)); + + // Render stale data during a reload rather than flashing a spinner. + final dives = divesAsync.valueOrNull?.take(widget.maxDives).toList(); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.plannerCanvas_follow_title, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (dives == null && divesAsync.isLoading) + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ) + else if (dives == null || dives.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + context.l10n.plannerCanvas_follow_empty, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: dives.length, + itemBuilder: (context, i) => _diveTile(dives[i], units), + ), + ), + ], + ), + ), + ); + } + + Widget _diveTile(Dive dive, UnitFormatter units) { + final date = MaterialLocalizations.of( + context, + ).formatShortDate(dive.entryTime ?? dive.dateTime); + final subtitleParts = [ + date, + if (dive.maxDepth != null) units.formatDepth(dive.maxDepth!), + if (dive.effectiveRuntime != null) '${dive.effectiveRuntime!.inMinutes}′', + ]; + final title = (dive.name?.isNotEmpty ?? false) + ? dive.name! + : (dive.diveNumber != null ? '#${dive.diveNumber}' : date); + + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(title), + subtitle: Text(subtitleParts.join(' · ')), + trailing: _loadingDiveId == dive.id + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + enabled: _loadingDiveId == null, + onTap: () => _follow(dive), + ); + } + + Future _follow(Dive dive) async { + setState(() => _loadingDiveId = dive.id); + final analysis = await ref.read(profileAnalysisProvider(dive.id).future); + if (!mounted) return; + + final compartments = (analysis != null && analysis.decoStatuses.isNotEmpty) + ? analysis.decoStatuses.last.compartments + : null; + + final end = (dive.entryTime ?? dive.dateTime).add( + dive.effectiveRuntime ?? Duration.zero, + ); + var interval = DateTime.now().difference(end); + if (interval < const Duration(minutes: 10)) { + interval = const Duration(hours: 1); + } + + ref + .read(divePlanNotifierProvider.notifier) + .setFollowedDive( + diveId: dive.id, + compartments: compartments, + surfaceInterval: interval, + ); + + final messenger = ScaffoldMessenger.of(context); + if (compartments == null) { + messenger.showSnackBar( + SnackBar(content: Text(context.l10n.plannerCanvas_follow_noTissues)), + ); + } + Navigator.of(context).pop(); + } +} diff --git a/lib/features/planner/presentation/widgets/plan_status_chips.dart b/lib/features/planner/presentation/widgets/plan_status_chips.dart index dd755be7c..fca69aab4 100644 --- a/lib/features/planner/presentation/widgets/plan_status_chips.dart +++ b/lib/features/planner/presentation/widgets/plan_status_chips.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -149,7 +151,35 @@ class PlanStatusChips extends ConsumerWidget { tint: planIssueSeverityColor(theme.colorScheme, maxSeverity), onTap: onIssuesTap, ), + const FollowingChip(), ], ); } } + +/// "Following {dive}" indicator with tap-to-clear; renders nothing when the +/// plan does not follow a logged dive. +class FollowingChip extends ConsumerWidget { + const FollowingChip({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sourceDiveId = ref.watch( + divePlanNotifierProvider.select((s) => s.sourceDiveId), + ); + if (sourceDiveId == null) return const SizedBox.shrink(); + + final dive = ref.watch(diveProvider(sourceDiveId)).valueOrNull; + final name = (dive?.name?.isNotEmpty ?? false) + ? dive!.name! + : (dive?.diveNumber != null ? '#${dive!.diveNumber}' : '…'); + + return PlanChip( + label: context.l10n.plannerCanvas_follow_chip(name), + value: '✕', + tint: Theme.of(context).colorScheme.tertiary, + onTap: () => + ref.read(divePlanNotifierProvider.notifier).clearFollowedDive(), + ); + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4bee5b48a..fa37c95ec 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5689,6 +5689,17 @@ "plannerCanvas_contingency_title": "Contingencies", "plannerCanvas_contingency_turnFraction": "Turn fraction", "plannerCanvas_contingency_turnRule": "Turn pressure rule", + "plannerCanvas_convert_success": "Dive created from plan", + "plannerCanvas_convert_view": "View", + "plannerCanvas_follow_chip": "Following {name}", + "@plannerCanvas_follow_chip": { + "placeholders": { + "name": { "type": "String" } + } + }, + "plannerCanvas_follow_empty": "No logged dives yet", + "plannerCanvas_follow_noTissues": "No profile data on that dive — surface interval set without tissue seeding", + "plannerCanvas_follow_title": "Follow a dive", "plannerCanvas_gas_minGas": "min gas {pressure}", "@plannerCanvas_gas_minGas": { "placeholders": { @@ -5728,6 +5739,7 @@ }, "plannerCanvas_issue_noBailout": "CCR decompression plan carries no bailout gas", "plannerCanvas_issue_noDecoGas": "Decompression required but no deco gas carried", + "plannerCanvas_overlay_plan": "Plan", "plannerCanvas_results_noDeco": "No decompression required", "plannerCanvas_saved_deleteConfirmBody": "Permanently delete \"{name}\"?", "@plannerCanvas_saved_deleteConfirmBody": { @@ -5736,6 +5748,12 @@ } }, "plannerCanvas_saved_deleteConfirmTitle": "Delete plan?", + "plannerCanvas_sac_useLogged": "Use logged average ({sac})", + "@plannerCanvas_sac_useLogged": { + "placeholders": { + "sac": { "type": "String" } + } + }, "plannerCanvas_saved_duplicate": "Duplicate", "plannerCanvas_saved_empty": "No saved plans yet", "plannerCanvas_saved_title": "Saved plans", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 34996ad3f..e859c8ce2 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17762,6 +17762,42 @@ abstract class AppLocalizations { /// **'Turn pressure rule'** String get plannerCanvas_contingency_turnRule; + /// No description provided for @plannerCanvas_convert_success. + /// + /// In en, this message translates to: + /// **'Dive created from plan'** + String get plannerCanvas_convert_success; + + /// No description provided for @plannerCanvas_convert_view. + /// + /// In en, this message translates to: + /// **'View'** + String get plannerCanvas_convert_view; + + /// No description provided for @plannerCanvas_follow_chip. + /// + /// In en, this message translates to: + /// **'Following {name}'** + String plannerCanvas_follow_chip(String name); + + /// No description provided for @plannerCanvas_follow_empty. + /// + /// In en, this message translates to: + /// **'No logged dives yet'** + String get plannerCanvas_follow_empty; + + /// No description provided for @plannerCanvas_follow_noTissues. + /// + /// In en, this message translates to: + /// **'No profile data on that dive — surface interval set without tissue seeding'** + String get plannerCanvas_follow_noTissues; + + /// No description provided for @plannerCanvas_follow_title. + /// + /// In en, this message translates to: + /// **'Follow a dive'** + String get plannerCanvas_follow_title; + /// No description provided for @plannerCanvas_gas_minGas. /// /// In en, this message translates to: @@ -17810,6 +17846,12 @@ abstract class AppLocalizations { /// **'Decompression required but no deco gas carried'** String get plannerCanvas_issue_noDecoGas; + /// No description provided for @plannerCanvas_overlay_plan. + /// + /// In en, this message translates to: + /// **'Plan'** + String get plannerCanvas_overlay_plan; + /// No description provided for @plannerCanvas_results_noDeco. /// /// In en, this message translates to: @@ -17828,6 +17870,12 @@ abstract class AppLocalizations { /// **'Delete plan?'** String get plannerCanvas_saved_deleteConfirmTitle; + /// No description provided for @plannerCanvas_sac_useLogged. + /// + /// In en, this message translates to: + /// **'Use logged average ({sac})'** + String plannerCanvas_sac_useLogged(String sac); + /// No description provided for @plannerCanvas_saved_duplicate. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 627b1dacc..012514d96 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10238,6 +10238,27 @@ class AppLocalizationsAr extends AppLocalizations { @override String get plannerCanvas_contingency_turnRule => 'قاعدة ضغط العودة'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'الحد الأدنى للغاز $pressure'; @@ -10276,6 +10297,9 @@ class AppLocalizationsAr extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'لا يلزم تخفيف الضغط'; @@ -10287,6 +10311,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'حذف الخطة؟'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'تكرار'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 4eaba8e82..a130b1abd 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10432,6 +10432,27 @@ class AppLocalizationsDe extends AppLocalizations { @override String get plannerCanvas_contingency_turnRule => 'Umkehrdruck-Regel'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'Min-Gas $pressure'; @@ -10470,6 +10491,9 @@ class AppLocalizationsDe extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Dekompression erforderlich, aber kein Deko-Gas mitgeführt'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'Keine Dekompression erforderlich'; @@ -10481,6 +10505,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Plan löschen?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Duplizieren'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index d289251bd..d1c2f7b7b 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10271,6 +10271,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get plannerCanvas_contingency_turnRule => 'Turn pressure rule'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'min gas $pressure'; @@ -10309,6 +10330,9 @@ class AppLocalizationsEn extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Decompression required but no deco gas carried'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'No decompression required'; @@ -10320,6 +10344,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Duplicate'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 66b10951d..cdb6a4bb4 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10422,6 +10422,27 @@ class AppLocalizationsEs extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'Regla de presión de retorno'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'gas mínimo $pressure'; @@ -10460,6 +10481,9 @@ class AppLocalizationsEs extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Se requiere descompresión pero no se lleva gas de deco'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'No se requiere descompresión'; @@ -10471,6 +10495,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => '¿Eliminar plan?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Duplicar'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 23d8b449e..8f9bb08aa 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10472,6 +10472,27 @@ class AppLocalizationsFr extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'Règle de pression de demi-tour'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'gaz mini $pressure'; @@ -10510,6 +10531,9 @@ class AppLocalizationsFr extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Décompression requise mais aucun gaz de déco emporté'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'Aucune décompression requise'; @@ -10521,6 +10545,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Supprimer le plan ?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Dupliquer'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 9a6754fe7..0ce820ad1 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10171,6 +10171,27 @@ class AppLocalizationsHe extends AppLocalizations { @override String get plannerCanvas_contingency_turnRule => 'כלל לחץ פנייה'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'גז מינימלי $pressure'; @@ -10209,6 +10230,9 @@ class AppLocalizationsHe extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'נדרשת דקומפרסיה אך לא נלקח גז דקו'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'לא נדרשת דקומפרסיה'; @@ -10220,6 +10244,11 @@ class AppLocalizationsHe extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'למחוק את התוכנית?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'שכפול'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index d793c343a..a0c756d28 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10408,6 +10408,27 @@ class AppLocalizationsHu extends AppLocalizations { @override String get plannerCanvas_contingency_turnRule => 'Fordulónyomás-szabály'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'min. gáz $pressure'; @@ -10446,6 +10467,9 @@ class AppLocalizationsHu extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Dekompresszió szükséges, de nincs deco gáz'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'Nincs szükség dekompresszióra'; @@ -10457,6 +10481,11 @@ class AppLocalizationsHu extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Törli a tervet?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Duplikálás'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 442f252a5..175f1115f 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10437,6 +10437,27 @@ class AppLocalizationsIt extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'Regola pressione di rientro'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'gas minimo $pressure'; @@ -10475,6 +10496,9 @@ class AppLocalizationsIt extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Decompressione necessaria ma nessun gas deco a bordo'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'Nessuna decompressione necessaria'; @@ -10487,6 +10511,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Eliminare il piano?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Duplica'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c6204db14..042e36c5a 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10359,6 +10359,27 @@ class AppLocalizationsNl extends AppLocalizations { @override String get plannerCanvas_contingency_turnRule => 'Keerdruk-regel'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'min. gas $pressure'; @@ -10397,6 +10418,9 @@ class AppLocalizationsNl extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Decompressie vereist maar geen decogas meegenomen'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'Geen decompressie vereist'; @@ -10408,6 +10432,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Plan verwijderen?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Dupliceren'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 9ca7f9166..34998d58f 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10443,6 +10443,27 @@ class AppLocalizationsPt extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'Regra de pressão de retorno'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return 'gás mínimo $pressure'; @@ -10481,6 +10502,9 @@ class AppLocalizationsPt extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Descompressão necessária mas sem gás de deco a bordo'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => 'Nenhuma descompressão necessária'; @@ -10492,6 +10516,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Excluir plano?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => 'Duplicar'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 56c411108..85751cc07 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9985,6 +9985,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get plannerCanvas_contingency_turnRule => '折返压力规则'; + @override + String get plannerCanvas_convert_success => 'Dive created from plan'; + + @override + String get plannerCanvas_convert_view => 'View'; + + @override + String plannerCanvas_follow_chip(String name) { + return 'Following $name'; + } + + @override + String get plannerCanvas_follow_empty => 'No logged dives yet'; + + @override + String get plannerCanvas_follow_noTissues => + 'No profile data on that dive — surface interval set without tissue seeding'; + + @override + String get plannerCanvas_follow_title => 'Follow a dive'; + @override String plannerCanvas_gas_minGas(String pressure) { return '最低气量 $pressure'; @@ -10021,6 +10042,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get plannerCanvas_issue_noDecoGas => '需要减压但未携带减压气体'; + @override + String get plannerCanvas_overlay_plan => 'Plan'; + @override String get plannerCanvas_results_noDeco => '无需减压'; @@ -10032,6 +10056,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => '删除计划?'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Use logged average ($sac)'; + } + @override String get plannerCanvas_saved_duplicate => '复制'; diff --git a/test/features/planner/follow_dive_test.dart b/test/features/planner/follow_dive_test.dart new file mode 100644 index 000000000..dab7ba750 --- /dev/null +++ b/test/features/planner/follow_dive_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +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/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/dive_planner/presentation/widgets/plan_settings_panel.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; +import 'package:submersion/features/planner/presentation/widgets/plan_status_chips.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../helpers/test_app.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() { + final stubDive = Dive( + id: 'dive-9', + diveNumber: 42, + name: 'Reef drift', + dateTime: DateTime(2026, 7, 1, 9), + runtime: const Duration(minutes: 45), + maxDepth: 30.0, + ); + + List overrides() => [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + diveProvider.overrideWith((ref, id) async => stubDive), + ]; + + testWidgets('FollowingChip appears when following and clears on tap', ( + tester, + ) async { + await tester.pumpWidget( + testApp(overrides: overrides(), child: const FollowingChip()), + ); + // Hidden with no followed dive. + expect(find.byType(PlanChip), findsNothing); + + final container = ProviderScope.containerOf( + tester.element(find.byType(FollowingChip)), + ); + container + .read(divePlanNotifierProvider.notifier) + .setFollowedDive( + diveId: 'dive-9', + compartments: null, + surfaceInterval: const Duration(hours: 1), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Reef drift'), findsOneWidget); + + await tester.tap(find.byType(PlanChip)); + await tester.pumpAndSettle(); + + expect(container.read(divePlanNotifierProvider).sourceDiveId, isNull); + expect(find.byType(PlanChip), findsNothing); + }); + + testWidgets('logged-average SAC button fills the plan SAC rate', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides(), + loggedAverageSacProvider.overrideWith((ref) async => 17.6), + ], + child: const SingleChildScrollView(child: PlanSettingsPanel()), + ), + ); + await tester.pumpAndSettle(); + + final button = find.textContaining('17.6'); + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pumpAndSettle(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanSettingsPanel)), + ); + expect( + container.read(divePlanNotifierProvider).sacRate, + closeTo(17.6, 0.01), + ); + // Button hides once the plan matches the logged average. + expect(find.textContaining('17.6 '), findsNothing); + }); +} From 080b768d89a9581dce78aa48b3a7e07dd25494e9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:02:27 -0400 Subject: [PATCH 5/8] feat(planner): persist convert-to-dive with plan back-link --- .../providers/dive_planner_providers.dart | 8 +- .../presentation/pages/plan_canvas_page.dart | 58 ++++++++++++- .../planner/convert_to_dive_test.dart | 84 +++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 test/features/planner/convert_to_dive_test.dart 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 8da15db81..20ba0e482 100644 --- a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart +++ b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart @@ -468,6 +468,9 @@ class DivePlanNotifier extends StateNotifier { } /// Convert the plan to a Dive entity for saving. + /// + /// The dive gets a fresh id (converting twice yields two dives) and its + /// tanks shed their plan-side ids so the repository generates new rows. Dive toDive() { // Generate profile points from segments final profilePoints = _calculator.generateProfilePoints(state.segments); @@ -487,12 +490,13 @@ class DivePlanNotifier extends StateNotifier { final avgDepth = totalTime > 0 ? totalDepthTime / totalTime : 0.0; return Dive( - id: state.id, + id: _uuid.v4(), + name: state.name, dateTime: DateTime.now(), runtime: Duration(seconds: totalTime), maxDepth: maxDepth, avgDepth: avgDepth, - tanks: state.tanks, + tanks: [for (final tank in state.tanks) tank.copyWith(id: '')], profile: profilePoints, notes: state.notes, altitude: state.altitude, diff --git a/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart index a0e7a498b..379bad205 100644 --- a/lib/features/planner/presentation/pages/plan_canvas_page.dart +++ b/lib/features/planner/presentation/pages/plan_canvas_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/plan_settings_panel.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/plan_tank_list.dart'; @@ -302,7 +305,7 @@ class _PlanCanvasPageState extends ConsumerState { ); } - void _convertToDive() { + Future _convertToDive() async { final isValid = ref.read(planIsValidProvider); if (!isValid) { ScaffoldMessenger.of(context).showSnackBar( @@ -313,12 +316,61 @@ class _PlanCanvasPageState extends ConsumerState { ); return; } - // Convert-to-dive persistence lands in Phase 6 (log integration). + + final notifier = ref.read(divePlanNotifierProvider.notifier); + final outcome = ref.read(planOutcomeProvider); + final series = ref.read(planCanvasSeriesProvider); + + // The state's segments stop at the bottom; the engine computes the + // ascent. Persist the full computed profile so the logged dive shows + // the deco schedule the plan produced. + final dive = notifier.toDive().copyWith( + profile: [ + for (final point in series.profile) + DiveProfilePoint( + timestamp: point.timeSeconds.round(), + depth: point.depth, + ), + ], + runtime: Duration(seconds: outcome.runtimeSeconds), + maxDepth: outcome.maxDepth, + avgDepth: _timeWeightedAverageDepth(series), + ); + + final created = await ref.read(diveRepositoryProvider).createDive(dive); + notifier.setLinkedDive(created.id); + await notifier.save( + summary: PlanSummaryData( + maxDepth: outcome.maxDepth, + runtimeSeconds: outcome.runtimeSeconds, + ttsSeconds: outcome.ttsAtBottom, + ), + ); + + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.divePlanner_message_convertingPlan)), + SnackBar( + content: Text(context.l10n.plannerCanvas_convert_success), + action: SnackBarAction( + label: context.l10n.plannerCanvas_convert_view, + onPressed: () => context.go('/dives/${created.id}'), + ), + ), ); } + static double _timeWeightedAverageDepth(PlanCanvasSeries series) { + final profile = series.profile; + if (profile.length < 2) return 0; + double weighted = 0; + for (var i = 1; i < profile.length; i++) { + final dt = profile[i].timeSeconds - profile[i - 1].timeSeconds; + weighted += dt * (profile[i].depth + profile[i - 1].depth) / 2; + } + final total = profile.last.timeSeconds - profile.first.timeSeconds; + return total > 0 ? weighted / total : 0; + } + void _showSettingsSheet(BuildContext context) { showModalBottomSheet( context: context, diff --git a/test/features/planner/convert_to_dive_test.dart b/test/features/planner/convert_to_dive_test.dart new file mode 100644 index 000000000..b79576476 --- /dev/null +++ b/test/features/planner/convert_to_dive_test.dart @@ -0,0 +1,84 @@ +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_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.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('converting a plan persists a planned dive and links it back', () async { + final notifier = container.read(divePlanNotifierProvider.notifier); + notifier.addSimplePlan(maxDepth: 30.0, bottomTimeMinutes: 20); + notifier.updateName('Convert me'); + final planId = container.read(divePlanNotifierProvider).id; + + // What the page's convert action does: persist toDive(), link, save. + final dive = notifier.toDive(); + expect(dive.id, isNot(planId)); + expect(dive.isPlanned, isTrue); + expect(dive.name, 'Convert me'); + + final created = await container + .read(diveRepositoryProvider) + .createDive(dive); + notifier.setLinkedDive(created.id); + await notifier.save(); + + // The dive round-trips from the repository as a planned dive. + final fetched = await container + .read(diveRepositoryProvider) + .getDiveById(created.id); + expect(fetched, isNotNull); + expect(fetched!.isPlanned, isTrue); + expect(fetched.tanks, isNotEmpty); + + // The plan aggregate carries the link. + final savedPlan = await container + .read(divePlanRepositoryProvider) + .getPlan(planId); + expect(savedPlan, isNotNull); + expect(savedPlan!.linkedDiveId, created.id); + }); + + test('converting twice yields two distinct dives', () async { + final notifier = container.read(divePlanNotifierProvider.notifier); + notifier.addSimplePlan(maxDepth: 20.0, bottomTimeMinutes: 15); + + final repository = container.read(diveRepositoryProvider); + final first = await repository.createDive(notifier.toDive()); + final second = await repository.createDive(notifier.toDive()); + expect(first.id, isNot(second.id)); + }); +} From 8d83c9057c22f0c1154e0d5cca852dd7d567967e Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:04:46 -0400 Subject: [PATCH 6/8] feat(planner): plan-vs-actual overlay on the dive detail profile chart --- .../presentation/pages/dive_detail_page.dart | 7 ++ .../repositories/dive_plan_repository.dart | 23 ++++++ .../providers/plan_overlay_provider.dart | 48 +++++++++++ test/features/planner/plan_overlay_test.dart | 81 +++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 lib/features/planner/presentation/providers/plan_overlay_provider.dart create mode 100644 test/features/planner/plan_overlay_test.dart diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 3c7fe1d8a..9b4ab577b 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -39,6 +39,7 @@ import 'package:submersion/features/dive_log/presentation/providers/gas_switch_p import 'package:submersion/features/dive_log/presentation/providers/profile_analysis_provider.dart'; import 'package:submersion/features/dive_log/presentation/pages/fullscreen_profile_page.dart'; import 'package:submersion/features/dive_log/presentation/utils/sac_normalization.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_overlay_provider.dart'; import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart'; import 'package:submersion/features/dive_log/presentation/providers/profile_playback_provider.dart'; import 'package:submersion/features/dive_log/presentation/providers/profile_tracking_provider.dart'; @@ -1152,6 +1153,11 @@ class _DiveDetailPageState extends ConsumerState { // rows (e.g. right after a split); skip any stale entries instead of // crashing on the lookup. final sourceById = {for (final s in dataSources) s.id: s}; + // Plan-vs-actual: the planned profile this dive was converted from, + // ghosted next to the actual logged profile. + final plannedOverlay = ref + .watch(plannedProfileOverlayProvider(dive.id)) + .valueOrNull; final overlays = [ for (final id in overlayIds) if (id != activeSource?.id && @@ -1168,6 +1174,7 @@ class _DiveDetailPageState extends ConsumerState { computerId: sourceProfiles[id]!.computerId, points: sourceProfiles[id]!.points, ), + ?plannedOverlay, ]; // Get unit formatter diff --git a/lib/features/planner/data/repositories/dive_plan_repository.dart b/lib/features/planner/data/repositories/dive_plan_repository.dart index 440546aa8..b0cd26d07 100644 --- a/lib/features/planner/data/repositories/dive_plan_repository.dart +++ b/lib/features/planner/data/repositories/dive_plan_repository.dart @@ -177,6 +177,29 @@ class DivePlanRepository { } } + /// The plan converted into [diveId] via convert-to-dive, or null. + /// Backs the plan-vs-actual overlay on the dive detail chart. + Future getPlanByLinkedDiveId(String diveId) async { + try { + // Newest first: a duplicated plan can share the link, take the latest. + final rows = + await (_db.select(_db.divePlans) + ..where((t) => t.linkedDiveId.equals(diveId)) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]) + ..limit(1)) + .get(); + if (rows.isEmpty) return null; + return getPlan(rows.first.id); + } catch (e, stackTrace) { + _log.error( + 'Failed to load plan linked to dive $diveId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + Future> getAllPlanSummaries() async { final rows = await (_db.select( _db.divePlans, diff --git a/lib/features/planner/presentation/providers/plan_overlay_provider.dart b/lib/features/planner/presentation/providers/plan_overlay_provider.dart new file mode 100644 index 000000000..463845519 --- /dev/null +++ b/lib/features/planner/presentation/providers/plan_overlay_provider.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_profile_chart.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_repository_providers.dart'; + +/// The planned profile for a dive created via convert-to-dive, rendered as +/// a chart overlay next to the actual logged profile ("plan vs actual"). +/// +/// Recomputes the plan through the same PlanEngine the canvas uses so the +/// ghost line always reflects the plan's full computed schedule (segments + +/// deco ascent), not just the stored bottom segments. Null when the dive +/// was not created from a plan. +final plannedProfileOverlayProvider = + FutureProvider.family((ref, diveId) async { + final repository = ref.watch(divePlanRepositoryProvider); + ref.invalidateSelfWhen(repository.watchPlanChanges()); + + final plan = await repository.getPlanByLinkedDiveId(diveId); + if (plan == null) return null; + + return buildPlannedOverlay(plan); + }); + +/// Pure mapping from a plan to its chart overlay (exposed for tests). +ChartSourceOverlay buildPlannedOverlay(domain.DivePlan plan) { + final outcome = const PlanEngine().compute(plan); + final series = buildCanvasSeries(segments: plan.segments, outcome: outcome); + + return ChartSourceOverlay( + sourceId: 'plan:${plan.id}', + name: plan.name, + color: Colors.purple, + computerId: null, + points: [ + for (final point in series.profile) + DiveProfilePoint( + timestamp: point.timeSeconds.round(), + depth: point.depth, + ), + ], + ); +} diff --git a/test/features/planner/plan_overlay_test.dart b/test/features/planner/plan_overlay_test.dart new file mode 100644 index 000000000..289b7908d --- /dev/null +++ b/test/features/planner/plan_overlay_test.dart @@ -0,0 +1,81 @@ +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_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_overlay_provider.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( + 'overlay resolves for a converted dive and maps the full profile', + () async { + final notifier = container.read(divePlanNotifierProvider.notifier); + notifier.addSimplePlan(maxDepth: 45.0, bottomTimeMinutes: 25); + notifier.updateName('Deco plan'); + + final created = await container + .read(diveRepositoryProvider) + .createDive(notifier.toDive()); + notifier.setLinkedDive(created.id); + await notifier.save(); + + final overlay = await container.read( + plannedProfileOverlayProvider(created.id).future, + ); + expect(overlay, isNotNull); + expect(overlay!.name, 'Deco plan'); + expect(overlay.sourceId, startsWith('plan:')); + // The overlay carries the full computed schedule: it descends to the + // planned bottom and ends back at the surface. + final depths = overlay.points.map((p) => p.depth); + expect(depths.reduce((a, b) => a > b ? a : b), closeTo(45.0, 0.01)); + expect(overlay.points.last.depth, 0.0); + // A 45 m / 25 min air plan carries deco, so the profile runs longer + // than the bottom segments alone. + expect(overlay.points.last.timestamp, greaterThan(25 * 60)); + }, + ); + + test('overlay is null for a dive with no linked plan', () async { + final repository = container.read(divePlanRepositoryProvider); + expect(await repository.getPlanByLinkedDiveId('no-such-dive'), isNull); + + final overlay = await container.read( + plannedProfileOverlayProvider('no-such-dive').future, + ); + expect(overlay, isNull); + }); +} From 1a86c7dae248a7ae400f90114cafc74af190169b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:08:06 -0400 Subject: [PATCH 7/8] chore(l10n): translate phase 6 planner strings into all locales --- lib/l10n/arb/app_ar.arb | 7 +++++++ lib/l10n/arb/app_de.arb | 7 +++++++ lib/l10n/arb/app_en.arb | 1 - lib/l10n/arb/app_es.arb | 7 +++++++ lib/l10n/arb/app_fr.arb | 7 +++++++ lib/l10n/arb/app_he.arb | 7 +++++++ lib/l10n/arb/app_hu.arb | 7 +++++++ lib/l10n/arb/app_it.arb | 7 +++++++ lib/l10n/arb/app_localizations.dart | 6 ------ lib/l10n/arb/app_localizations_ar.dart | 17 +++++++---------- lib/l10n/arb/app_localizations_de.dart | 17 +++++++---------- lib/l10n/arb/app_localizations_en.dart | 3 --- lib/l10n/arb/app_localizations_es.dart | 18 ++++++++---------- lib/l10n/arb/app_localizations_fr.dart | 18 ++++++++---------- lib/l10n/arb/app_localizations_he.dart | 17 +++++++---------- lib/l10n/arb/app_localizations_hu.dart | 17 +++++++---------- lib/l10n/arb/app_localizations_it.dart | 17 +++++++---------- lib/l10n/arb/app_localizations_nl.dart | 17 +++++++---------- lib/l10n/arb/app_localizations_pt.dart | 18 ++++++++---------- lib/l10n/arb/app_localizations_zh.dart | 18 +++++++----------- lib/l10n/arb/app_nl.arb | 7 +++++++ lib/l10n/arb/app_pt.arb | 7 +++++++ lib/l10n/arb/app_zh.arb | 7 +++++++ 23 files changed, 143 insertions(+), 111 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 9d06cf015..7d84e5282 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3253,6 +3253,12 @@ "plannerCanvas_contingency_title": "خطط الطوارئ", "plannerCanvas_contingency_turnFraction": "نسبة العودة", "plannerCanvas_contingency_turnRule": "قاعدة ضغط العودة", + "plannerCanvas_convert_success": "تم إنشاء غطسة من الخطة", + "plannerCanvas_convert_view": "عرض", + "plannerCanvas_follow_chip": "يتبع {name}", + "plannerCanvas_follow_empty": "لا توجد غطسات مسجلة بعد", + "plannerCanvas_follow_noTissues": "لا توجد بيانات ملف تعريف لهذه الغطسة — تم ضبط الفاصل السطحي دون تحميل الأنسجة", + "plannerCanvas_follow_title": "اتباع غطسة", "plannerCanvas_gas_minGas": "الحد الأدنى للغاز {pressure}", "plannerCanvas_gas_turnAt": "العودة عند {pressure}", "plannerCanvas_issue_gasDensityCritical": "كثافة الغاز {value} g/L تتجاوز الحد الأقصى", @@ -3264,6 +3270,7 @@ "plannerCanvas_results_noDeco": "لا يلزم تخفيف الضغط", "plannerCanvas_saved_deleteConfirmBody": "حذف \"{name}\" نهائيًا؟", "plannerCanvas_saved_deleteConfirmTitle": "حذف الخطة؟", + "plannerCanvas_sac_useLogged": "استخدام المتوسط المسجل ({sac})", "plannerCanvas_saved_duplicate": "تكرار", "plannerCanvas_saved_empty": "لا توجد خطط محفوظة بعد", "plannerCanvas_saved_title": "الخطط المحفوظة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 64314f486..97e4c0a3b 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3253,6 +3253,12 @@ "plannerCanvas_contingency_title": "Eventualpläne", "plannerCanvas_contingency_turnFraction": "Umkehranteil", "plannerCanvas_contingency_turnRule": "Umkehrdruck-Regel", + "plannerCanvas_convert_success": "Tauchgang aus Plan erstellt", + "plannerCanvas_convert_view": "Anzeigen", + "plannerCanvas_follow_chip": "Folgt {name}", + "plannerCanvas_follow_empty": "Noch keine geloggten Tauchgänge", + "plannerCanvas_follow_noTissues": "Keine Profildaten für diesen Tauchgang — Oberflächenpause ohne Gewebesättigung übernommen", + "plannerCanvas_follow_title": "Einem Tauchgang folgen", "plannerCanvas_gas_minGas": "Min-Gas {pressure}", "plannerCanvas_gas_turnAt": "Umkehr bei {pressure}", "plannerCanvas_issue_gasDensityCritical": "Gasdichte {value} g/L über hartem Limit", @@ -3264,6 +3270,7 @@ "plannerCanvas_results_noDeco": "Keine Dekompression erforderlich", "plannerCanvas_saved_deleteConfirmBody": "„{name}“ endgültig löschen?", "plannerCanvas_saved_deleteConfirmTitle": "Plan löschen?", + "plannerCanvas_sac_useLogged": "Geloggten Durchschnitt verwenden ({sac})", "plannerCanvas_saved_duplicate": "Duplizieren", "plannerCanvas_saved_empty": "Noch keine gespeicherten Pläne", "plannerCanvas_saved_title": "Gespeicherte Pläne", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index fa37c95ec..7f755f7ab 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5739,7 +5739,6 @@ }, "plannerCanvas_issue_noBailout": "CCR decompression plan carries no bailout gas", "plannerCanvas_issue_noDecoGas": "Decompression required but no deco gas carried", - "plannerCanvas_overlay_plan": "Plan", "plannerCanvas_results_noDeco": "No decompression required", "plannerCanvas_saved_deleteConfirmBody": "Permanently delete \"{name}\"?", "@plannerCanvas_saved_deleteConfirmBody": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 425a86bdd..8f6ce68c6 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3253,6 +3253,12 @@ "plannerCanvas_contingency_title": "Contingencias", "plannerCanvas_contingency_turnFraction": "Fracción de retorno", "plannerCanvas_contingency_turnRule": "Regla de presión de retorno", + "plannerCanvas_convert_success": "Inmersión creada a partir del plan", + "plannerCanvas_convert_view": "Ver", + "plannerCanvas_follow_chip": "Siguiendo {name}", + "plannerCanvas_follow_empty": "Aún no hay inmersiones registradas", + "plannerCanvas_follow_noTissues": "Sin datos de perfil en esa inmersión — intervalo en superficie establecido sin saturación de tejidos", + "plannerCanvas_follow_title": "Seguir una inmersión", "plannerCanvas_gas_minGas": "gas mínimo {pressure}", "plannerCanvas_gas_turnAt": "retorno a {pressure}", "plannerCanvas_issue_gasDensityCritical": "Densidad del gas {value} g/L sobre el límite máximo", @@ -3264,6 +3270,7 @@ "plannerCanvas_results_noDeco": "No se requiere descompresión", "plannerCanvas_saved_deleteConfirmBody": "¿Eliminar permanentemente «{name}»?", "plannerCanvas_saved_deleteConfirmTitle": "¿Eliminar plan?", + "plannerCanvas_sac_useLogged": "Usar promedio registrado ({sac})", "plannerCanvas_saved_duplicate": "Duplicar", "plannerCanvas_saved_empty": "Aún no hay planes guardados", "plannerCanvas_saved_title": "Planes guardados", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index cb5da7f8b..703abde30 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3219,6 +3219,12 @@ "plannerCanvas_contingency_title": "Plans de secours", "plannerCanvas_contingency_turnFraction": "Fraction de demi-tour", "plannerCanvas_contingency_turnRule": "Règle de pression de demi-tour", + "plannerCanvas_convert_success": "Plongée créée à partir du plan", + "plannerCanvas_convert_view": "Voir", + "plannerCanvas_follow_chip": "Suit {name}", + "plannerCanvas_follow_empty": "Aucune plongée enregistrée pour l'instant", + "plannerCanvas_follow_noTissues": "Pas de données de profil pour cette plongée — intervalle de surface défini sans saturation des tissus", + "plannerCanvas_follow_title": "Suivre une plongée", "plannerCanvas_gas_minGas": "gaz mini {pressure}", "plannerCanvas_gas_turnAt": "demi-tour à {pressure}", "plannerCanvas_issue_gasDensityCritical": "Densité du gaz {value} g/L au-dessus de la limite stricte", @@ -3230,6 +3236,7 @@ "plannerCanvas_results_noDeco": "Aucune décompression requise", "plannerCanvas_saved_deleteConfirmBody": "Supprimer définitivement « {name} » ?", "plannerCanvas_saved_deleteConfirmTitle": "Supprimer le plan ?", + "plannerCanvas_sac_useLogged": "Utiliser la moyenne enregistrée ({sac})", "plannerCanvas_saved_duplicate": "Dupliquer", "plannerCanvas_saved_empty": "Aucun plan enregistré", "plannerCanvas_saved_title": "Plans enregistrés", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 0641b3b26..088a31044 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3219,6 +3219,12 @@ "plannerCanvas_contingency_title": "תוכניות חירום", "plannerCanvas_contingency_turnFraction": "שבר פנייה", "plannerCanvas_contingency_turnRule": "כלל לחץ פנייה", + "plannerCanvas_convert_success": "נוצרה צלילה מהתוכנית", + "plannerCanvas_convert_view": "הצג", + "plannerCanvas_follow_chip": "עוקב אחרי {name}", + "plannerCanvas_follow_empty": "אין עדיין צלילות מתועדות", + "plannerCanvas_follow_noTissues": "אין נתוני פרופיל לצלילה זו — מרווח הפנים נקבע ללא העמסת רקמות", + "plannerCanvas_follow_title": "עקוב אחרי צלילה", "plannerCanvas_gas_minGas": "גז מינימלי {pressure}", "plannerCanvas_gas_turnAt": "פנייה ב-{pressure}", "plannerCanvas_issue_gasDensityCritical": "צפיפות הגז {value} g/L מעל הגבול המרבי", @@ -3230,6 +3236,7 @@ "plannerCanvas_results_noDeco": "לא נדרשת דקומפרסיה", "plannerCanvas_saved_deleteConfirmBody": "למחוק לצמיתות את \"{name}\"?", "plannerCanvas_saved_deleteConfirmTitle": "למחוק את התוכנית?", + "plannerCanvas_sac_useLogged": "השתמש בממוצע המתועד ({sac})", "plannerCanvas_saved_duplicate": "שכפול", "plannerCanvas_saved_empty": "אין עדיין תוכניות שמורות", "plannerCanvas_saved_title": "תוכניות שמורות", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index f6d4b507b..57087b267 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3219,6 +3219,12 @@ "plannerCanvas_contingency_title": "Vészhelyzeti tervek", "plannerCanvas_contingency_turnFraction": "Fordulási hányad", "plannerCanvas_contingency_turnRule": "Fordulónyomás-szabály", + "plannerCanvas_convert_success": "Merülés létrehozva a tervből", + "plannerCanvas_convert_view": "Megtekintés", + "plannerCanvas_follow_chip": "{name} követése", + "plannerCanvas_follow_empty": "Még nincsenek naplózott merülések", + "plannerCanvas_follow_noTissues": "Nincs profiladat ehhez a merüléshez — a felszíni intervallum szövettelítés nélkül lett beállítva", + "plannerCanvas_follow_title": "Merülés követése", "plannerCanvas_gas_minGas": "min. gáz {pressure}", "plannerCanvas_gas_turnAt": "fordulás {pressure}-nál", "plannerCanvas_issue_gasDensityCritical": "Gázsűrűség {value} g/L a szigorú határ felett", @@ -3230,6 +3236,7 @@ "plannerCanvas_results_noDeco": "Nincs szükség dekompresszióra", "plannerCanvas_saved_deleteConfirmBody": "Véglegesen törli a(z) „{name}” tervet?", "plannerCanvas_saved_deleteConfirmTitle": "Törli a tervet?", + "plannerCanvas_sac_useLogged": "Naplózott átlag használata ({sac})", "plannerCanvas_saved_duplicate": "Duplikálás", "plannerCanvas_saved_empty": "Még nincsenek mentett tervek", "plannerCanvas_saved_title": "Mentett tervek", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 3bc676ead..7493519bf 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3219,6 +3219,12 @@ "plannerCanvas_contingency_title": "Contingenze", "plannerCanvas_contingency_turnFraction": "Frazione di rientro", "plannerCanvas_contingency_turnRule": "Regola pressione di rientro", + "plannerCanvas_convert_success": "Immersione creata dal piano", + "plannerCanvas_convert_view": "Vedi", + "plannerCanvas_follow_chip": "Segue {name}", + "plannerCanvas_follow_empty": "Nessuna immersione registrata", + "plannerCanvas_follow_noTissues": "Nessun dato di profilo per quell'immersione — intervallo di superficie impostato senza saturazione dei tessuti", + "plannerCanvas_follow_title": "Segui un'immersione", "plannerCanvas_gas_minGas": "gas minimo {pressure}", "plannerCanvas_gas_turnAt": "rientro a {pressure}", "plannerCanvas_issue_gasDensityCritical": "Densità del gas {value} g/L oltre il limite massimo", @@ -3230,6 +3236,7 @@ "plannerCanvas_results_noDeco": "Nessuna decompressione necessaria", "plannerCanvas_saved_deleteConfirmBody": "Eliminare definitivamente «{name}»?", "plannerCanvas_saved_deleteConfirmTitle": "Eliminare il piano?", + "plannerCanvas_sac_useLogged": "Usa media registrata ({sac})", "plannerCanvas_saved_duplicate": "Duplica", "plannerCanvas_saved_empty": "Nessun piano salvato", "plannerCanvas_saved_title": "Piani salvati", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index e859c8ce2..8fb86edd5 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17846,12 +17846,6 @@ abstract class AppLocalizations { /// **'Decompression required but no deco gas carried'** String get plannerCanvas_issue_noDecoGas; - /// No description provided for @plannerCanvas_overlay_plan. - /// - /// In en, this message translates to: - /// **'Plan'** - String get plannerCanvas_overlay_plan; - /// No description provided for @plannerCanvas_results_noDeco. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 012514d96..4bbffb3f4 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10239,25 +10239,25 @@ class AppLocalizationsAr extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'قاعدة ضغط العودة'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => 'تم إنشاء غطسة من الخطة'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'عرض'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'يتبع $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'لا توجد غطسات مسجلة بعد'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'لا توجد بيانات ملف تعريف لهذه الغطسة — تم ضبط الفاصل السطحي دون تحميل الأنسجة'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'اتباع غطسة'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10297,9 +10297,6 @@ class AppLocalizationsAr extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'لا يلزم تخفيف الضغط'; @@ -10313,7 +10310,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'استخدام المتوسط المسجل ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index a130b1abd..9a5a7cc8e 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10433,25 +10433,25 @@ class AppLocalizationsDe extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'Umkehrdruck-Regel'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => 'Tauchgang aus Plan erstellt'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'Anzeigen'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'Folgt $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'Noch keine geloggten Tauchgänge'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'Keine Profildaten für diesen Tauchgang — Oberflächenpause ohne Gewebesättigung übernommen'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'Einem Tauchgang folgen'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10491,9 +10491,6 @@ class AppLocalizationsDe extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Dekompression erforderlich, aber kein Deko-Gas mitgeführt'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'Keine Dekompression erforderlich'; @@ -10507,7 +10504,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'Geloggten Durchschnitt verwenden ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index d1c2f7b7b..11ac1e64d 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10330,9 +10330,6 @@ class AppLocalizationsEn extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Decompression required but no deco gas carried'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'No decompression required'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index cdb6a4bb4..4ef2bc2aa 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10423,25 +10423,26 @@ class AppLocalizationsEs extends AppLocalizations { 'Regla de presión de retorno'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => + 'Inmersión creada a partir del plan'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'Ver'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'Siguiendo $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'Aún no hay inmersiones registradas'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'Sin datos de perfil en esa inmersión — intervalo en superficie establecido sin saturación de tejidos'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'Seguir una inmersión'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10481,9 +10482,6 @@ class AppLocalizationsEs extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Se requiere descompresión pero no se lleva gas de deco'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'No se requiere descompresión'; @@ -10497,7 +10495,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'Usar promedio registrado ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 8f9bb08aa..4e8b15a56 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10473,25 +10473,26 @@ class AppLocalizationsFr extends AppLocalizations { 'Règle de pression de demi-tour'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => 'Plongée créée à partir du plan'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'Voir'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'Suit $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => + 'Aucune plongée enregistrée pour l\'instant'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'Pas de données de profil pour cette plongée — intervalle de surface défini sans saturation des tissus'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'Suivre une plongée'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10531,9 +10532,6 @@ class AppLocalizationsFr extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Décompression requise mais aucun gaz de déco emporté'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'Aucune décompression requise'; @@ -10547,7 +10545,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'Utiliser la moyenne enregistrée ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 0ce820ad1..4f6ef0ec7 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10172,25 +10172,25 @@ class AppLocalizationsHe extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'כלל לחץ פנייה'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => 'נוצרה צלילה מהתוכנית'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'הצג'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'עוקב אחרי $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'אין עדיין צלילות מתועדות'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'אין נתוני פרופיל לצלילה זו — מרווח הפנים נקבע ללא העמסת רקמות'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'עקוב אחרי צלילה'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10230,9 +10230,6 @@ class AppLocalizationsHe extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'נדרשת דקומפרסיה אך לא נלקח גז דקו'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'לא נדרשת דקומפרסיה'; @@ -10246,7 +10243,7 @@ class AppLocalizationsHe extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'השתמש בממוצע המתועד ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index a0c756d28..4e4fda62a 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10409,25 +10409,25 @@ class AppLocalizationsHu extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'Fordulónyomás-szabály'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => 'Merülés létrehozva a tervből'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'Megtekintés'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return '$name követése'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'Még nincsenek naplózott merülések'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'Nincs profiladat ehhez a merüléshez — a felszíni intervallum szövettelítés nélkül lett beállítva'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'Merülés követése'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10467,9 +10467,6 @@ class AppLocalizationsHu extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Dekompresszió szükséges, de nincs deco gáz'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'Nincs szükség dekompresszióra'; @@ -10483,7 +10480,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'Naplózott átlag használata ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 175f1115f..187d94bcd 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10438,25 +10438,25 @@ class AppLocalizationsIt extends AppLocalizations { 'Regola pressione di rientro'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => 'Immersione creata dal piano'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'Vedi'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'Segue $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'Nessuna immersione registrata'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'Nessun dato di profilo per quell\'immersione — intervallo di superficie impostato senza saturazione dei tessuti'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'Segui un\'immersione'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10496,9 +10496,6 @@ class AppLocalizationsIt extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Decompressione necessaria ma nessun gas deco a bordo'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'Nessuna decompressione necessaria'; @@ -10513,7 +10510,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'Usa media registrata ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 042e36c5a..938e4dc8c 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10360,25 +10360,25 @@ class AppLocalizationsNl extends AppLocalizations { String get plannerCanvas_contingency_turnRule => 'Keerdruk-regel'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => 'Duik aangemaakt vanuit plan'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'Bekijk'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'Volgt $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'Nog geen gelogde duiken'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'Geen profielgegevens voor die duik — oppervlakte-interval ingesteld zonder weefselverzadiging'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'Volg een duik'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10418,9 +10418,6 @@ class AppLocalizationsNl extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Decompressie vereist maar geen decogas meegenomen'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'Geen decompressie vereist'; @@ -10434,7 +10431,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'Gebruik gelogd gemiddelde ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 34998d58f..62ca6103d 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10444,25 +10444,26 @@ class AppLocalizationsPt extends AppLocalizations { 'Regra de pressão de retorno'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => + 'Mergulho criado a partir do plano'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => 'Ver'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return 'Seguindo $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => 'Ainda não há mergulhos registrados'; @override String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + 'Sem dados de perfil nesse mergulho — intervalo de superfície definido sem saturação dos tecidos'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => 'Seguir um mergulho'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10502,9 +10503,6 @@ class AppLocalizationsPt extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Descompressão necessária mas sem gás de deco a bordo'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => 'Nenhuma descompressão necessária'; @@ -10518,7 +10516,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return 'Usar média registrada ($sac)'; } @override diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 85751cc07..4e30e3780 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9986,25 +9986,24 @@ class AppLocalizationsZh extends AppLocalizations { String get plannerCanvas_contingency_turnRule => '折返压力规则'; @override - String get plannerCanvas_convert_success => 'Dive created from plan'; + String get plannerCanvas_convert_success => '已从计划创建潜水'; @override - String get plannerCanvas_convert_view => 'View'; + String get plannerCanvas_convert_view => '查看'; @override String plannerCanvas_follow_chip(String name) { - return 'Following $name'; + return '跟随 $name'; } @override - String get plannerCanvas_follow_empty => 'No logged dives yet'; + String get plannerCanvas_follow_empty => '还没有记录的潜水'; @override - String get plannerCanvas_follow_noTissues => - 'No profile data on that dive — surface interval set without tissue seeding'; + String get plannerCanvas_follow_noTissues => '该潜水没有剖面数据 — 已设置水面间隔但未载入组织饱和度'; @override - String get plannerCanvas_follow_title => 'Follow a dive'; + String get plannerCanvas_follow_title => '跟随一次潜水'; @override String plannerCanvas_gas_minGas(String pressure) { @@ -10042,9 +10041,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get plannerCanvas_issue_noDecoGas => '需要减压但未携带减压气体'; - @override - String get plannerCanvas_overlay_plan => 'Plan'; - @override String get plannerCanvas_results_noDeco => '无需减压'; @@ -10058,7 +10054,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String plannerCanvas_sac_useLogged(String sac) { - return 'Use logged average ($sac)'; + return '使用记录的平均值($sac)'; } @override diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index e798c46fc..eb131bf53 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3253,6 +3253,12 @@ "plannerCanvas_contingency_title": "Noodplannen", "plannerCanvas_contingency_turnFraction": "Keerfractie", "plannerCanvas_contingency_turnRule": "Keerdruk-regel", + "plannerCanvas_convert_success": "Duik aangemaakt vanuit plan", + "plannerCanvas_convert_view": "Bekijk", + "plannerCanvas_follow_chip": "Volgt {name}", + "plannerCanvas_follow_empty": "Nog geen gelogde duiken", + "plannerCanvas_follow_noTissues": "Geen profielgegevens voor die duik — oppervlakte-interval ingesteld zonder weefselverzadiging", + "plannerCanvas_follow_title": "Volg een duik", "plannerCanvas_gas_minGas": "min. gas {pressure}", "plannerCanvas_gas_turnAt": "keer om bij {pressure}", "plannerCanvas_issue_gasDensityCritical": "Gasdichtheid {value} g/L boven harde limiet", @@ -3264,6 +3270,7 @@ "plannerCanvas_results_noDeco": "Geen decompressie vereist", "plannerCanvas_saved_deleteConfirmBody": "'{name}' definitief verwijderen?", "plannerCanvas_saved_deleteConfirmTitle": "Plan verwijderen?", + "plannerCanvas_sac_useLogged": "Gebruik gelogd gemiddelde ({sac})", "plannerCanvas_saved_duplicate": "Dupliceren", "plannerCanvas_saved_empty": "Nog geen opgeslagen plannen", "plannerCanvas_saved_title": "Opgeslagen plannen", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index dc1320966..8737b315d 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3253,6 +3253,12 @@ "plannerCanvas_contingency_title": "Contingências", "plannerCanvas_contingency_turnFraction": "Fração de retorno", "plannerCanvas_contingency_turnRule": "Regra de pressão de retorno", + "plannerCanvas_convert_success": "Mergulho criado a partir do plano", + "plannerCanvas_convert_view": "Ver", + "plannerCanvas_follow_chip": "Seguindo {name}", + "plannerCanvas_follow_empty": "Ainda não há mergulhos registrados", + "plannerCanvas_follow_noTissues": "Sem dados de perfil nesse mergulho — intervalo de superfície definido sem saturação dos tecidos", + "plannerCanvas_follow_title": "Seguir um mergulho", "plannerCanvas_gas_minGas": "gás mínimo {pressure}", "plannerCanvas_gas_turnAt": "retorno a {pressure}", "plannerCanvas_issue_gasDensityCritical": "Densidade do gás {value} g/L acima do limite máximo", @@ -3264,6 +3270,7 @@ "plannerCanvas_results_noDeco": "Nenhuma descompressão necessária", "plannerCanvas_saved_deleteConfirmBody": "Excluir permanentemente \"{name}\"?", "plannerCanvas_saved_deleteConfirmTitle": "Excluir plano?", + "plannerCanvas_sac_useLogged": "Usar média registrada ({sac})", "plannerCanvas_saved_duplicate": "Duplicar", "plannerCanvas_saved_empty": "Ainda não há planos salvos", "plannerCanvas_saved_title": "Planos salvos", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 8dbf4ef1a..fded47dc1 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3403,6 +3403,12 @@ "plannerCanvas_contingency_title": "应急计划", "plannerCanvas_contingency_turnFraction": "折返比例", "plannerCanvas_contingency_turnRule": "折返压力规则", + "plannerCanvas_convert_success": "已从计划创建潜水", + "plannerCanvas_convert_view": "查看", + "plannerCanvas_follow_chip": "跟随 {name}", + "plannerCanvas_follow_empty": "还没有记录的潜水", + "plannerCanvas_follow_noTissues": "该潜水没有剖面数据 — 已设置水面间隔但未载入组织饱和度", + "plannerCanvas_follow_title": "跟随一次潜水", "plannerCanvas_gas_minGas": "最低气量 {pressure}", "plannerCanvas_gas_turnAt": "{pressure} 时折返", "plannerCanvas_issue_gasDensityCritical": "气体密度 {value} g/L 超过硬性上限", @@ -3414,6 +3420,7 @@ "plannerCanvas_results_noDeco": "无需减压", "plannerCanvas_saved_deleteConfirmBody": "永久删除“{name}”?", "plannerCanvas_saved_deleteConfirmTitle": "删除计划?", + "plannerCanvas_sac_useLogged": "使用记录的平均值({sac})", "plannerCanvas_saved_duplicate": "复制", "plannerCanvas_saved_empty": "尚无已保存的计划", "plannerCanvas_saved_title": "已保存的计划", From b58ab22602e3c517c4ec9ff42f883fef9e9f99a6 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 17:06:51 -0400 Subject: [PATCH 8/8] test(planner): raise phase 6 log-integration coverage; address review comment - Altitude <= 0 is treated as unset (legacy 1.0 bar surface) in both the engine and the seeded-tissue environment, so a literal 0 no longer switches to barometric sea-level pressure and shifts the deco math - Cover the follow-a-dive sheet (list, follow-on-tap, empty state) --- .../planner/domain/services/plan_engine.dart | 5 +- .../providers/plan_canvas_providers.dart | 6 +- test/features/planner/follow_dive_test.dart | 57 +++++++++++++++++++ .../planner/plan_engine_schedule_test.dart | 13 +++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/lib/features/planner/domain/services/plan_engine.dart b/lib/features/planner/domain/services/plan_engine.dart index aa1657d2c..e55de44fb 100644 --- a/lib/features/planner/domain/services/plan_engine.dart +++ b/lib/features/planner/domain/services/plan_engine.dart @@ -93,7 +93,10 @@ class PlanEngine { } final isCcr = plan.mode == domain.PlanMode.ccr; final environment = DiveEnvironment.forConditions( - altitudeMeters: plan.altitude, + // Altitude <= 0 is treated as unset (legacy 1.0 bar surface), matching + // the rest of the planner — a literal 0 must not switch to barometric + // sea-level pressure and subtly change the deco math. + altitudeMeters: (plan.altitude ?? 0) > 0 ? plan.altitude : null, waterType: plan.waterType, ); final policy = SchedulePolicy( diff --git a/lib/features/planner/presentation/providers/plan_canvas_providers.dart b/lib/features/planner/presentation/providers/plan_canvas_providers.dart index 48533f9bf..97c22c0f7 100644 --- a/lib/features/planner/presentation/providers/plan_canvas_providers.dart +++ b/lib/features/planner/presentation/providers/plan_canvas_providers.dart @@ -32,7 +32,11 @@ final planOutcomeProvider = Provider((ref) { surfaceInterval: state.surfaceInterval, gfLow: state.gfLow / 100.0, gfHigh: state.gfHigh / 100.0, - environment: DiveEnvironment.forConditions(altitudeMeters: state.altitude), + // Match the engine: altitude <= 0 is unset (legacy 1.0 bar), so the seed + // is off-gassed at the same surface pressure the plan is computed at. + environment: DiveEnvironment.forConditions( + altitudeMeters: (state.altitude ?? 0) > 0 ? state.altitude : null, + ), ); return engine.compute(divePlanFromState(state), startState: startState); }); diff --git a/test/features/planner/follow_dive_test.dart b/test/features/planner/follow_dive_test.dart index dab7ba750..dd4c688db 100644 --- a/test/features/planner/follow_dive_test.dart +++ b/test/features/planner/follow_dive_test.dart @@ -4,9 +4,11 @@ import 'package:submersion/core/constants/map_style.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/profile_analysis_provider.dart'; import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/plan_settings_panel.dart'; import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; +import 'package:submersion/features/planner/presentation/widgets/follow_dive_sheet.dart'; import 'package:submersion/features/planner/presentation/widgets/plan_status_chips.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -99,4 +101,59 @@ void main() { // Button hides once the plan matches the logged average. expect(find.textContaining('17.6 '), findsNothing); }); + + testWidgets('FollowDiveSheet lists dives and follows the tapped one', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + divesProvider.overrideWith((ref) async => [stubDive]), + // No profile analysis -> follows with a surface interval but no + // tissue seed (the null-compartments branch). + profileAnalysisProvider.overrideWith((ref, id) async => null), + ], + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showFollowDiveSheet(context), + child: const Text('open'), + ), + ), + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.text('open')), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.byType(FollowDiveSheet), findsOneWidget); + expect(find.text('Reef drift'), findsOneWidget); + + await tester.tap(find.text('Reef drift')); + await tester.pumpAndSettle(); + + final state = container.read(divePlanNotifierProvider); + expect(state.sourceDiveId, 'dive-9'); + expect(state.surfaceInterval, isNotNull); + // The sheet closed after following. + expect(find.byType(FollowDiveSheet), findsNothing); + }); + + testWidgets('FollowDiveSheet shows an empty state with no logged dives', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + divesProvider.overrideWith((ref) async => []), + ], + child: const FollowDiveSheet(), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('No logged dives yet'), findsOneWidget); + }); } diff --git a/test/features/planner/plan_engine_schedule_test.dart b/test/features/planner/plan_engine_schedule_test.dart index ed4cfbf6f..3802bc1af 100644 --- a/test/features/planner/plan_engine_schedule_test.dart +++ b/test/features/planner/plan_engine_schedule_test.dart @@ -177,5 +177,18 @@ void main() { expect(outcome.segmentOutcomes, hasLength(2)); expect(outcome.segmentOutcomes.last.inDeco, isTrue); }); + + test('altitude 0 is treated as unset (legacy surface), not barometric', () { + const engine = PlanEngine(); + final unset = engine.compute(_plan()); + final zero = engine.compute(_plan().copyWith(altitude: 0)); + final real = engine.compute(_plan().copyWith(altitude: 2000)); + + // A literal 0 must reproduce the unset (1.0 bar) schedule exactly. + expect(zero.ttsAtBottom, unset.ttsAtBottom); + expect(zero.totalDecoSeconds, unset.totalDecoSeconds); + // A real altitude thins the surface pressure and lengthens deco. + expect(real.totalDecoSeconds, greaterThan(unset.totalDecoSeconds)); + }); }); }