From 22d0e1bb4853a20958b3662253eed8de65c0ce75 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 13:57:52 -0400 Subject: [PATCH 1/7] docs: add Phase 4 CCR + bailout implementation plan Claude-Session: https://claude.ai/code/session_01CMosDc8A1zcntatqFZba1k --- .../2026-07-05-dive-planner-phase4-ccr.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-dive-planner-phase4-ccr.md diff --git a/docs/superpowers/plans/2026-07-05-dive-planner-phase4-ccr.md b/docs/superpowers/plans/2026-07-05-dive-planner-phase4-ccr.md new file mode 100644 index 000000000..ca9243176 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dive-planner-phase4-ccr.md @@ -0,0 +1,106 @@ +# Dive Planner Phase 4: CCR Planning + Bailout — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Real CCR planning — constant-ppO2 loading AND loop deco schedules (removing the "CCR schedules breathe OC" Phase 1 limitation), setpoint low/high with a depth switch, O2/diluent consumption, a worst-case bailout solver with scrub-to-bailout readout, and the canvas UI for all of it. + +**Architecture (the key move):** the constant-ppO2 loop is expressed as a **depth-dependent effective-fraction `AscentGasPlan`** (`CcrLoopAscentGas`): at depth d, the loop's inspired partial pressures (via `ClosedCircuit.inspiredAt`) divide by alveolar pressure to give effective fN2/fHe — so the UNCHANGED Bühlmann schedule machinery computes loop deco exactly at constant-depth stops. No engine-core edits. Branch: stacked on `worktree-dive-planner-phase3-canvas` (PR #486). + +**Spec:** "CCR and bailout (Phase 4)" section. Global constraints as Phase 3 (l10n all locales, units, format/analyze, commit per task, no Co-Authored-By). + +--- + +### Task 1: `CcrLoopAscentGas` (core engine, additive) + +**Files:** Create `lib/core/deco/ascent/ccr_loop_ascent_gas.dart`; Test `test/core/deco/ccr_loop_ascent_gas_test.dart`. + +```dart +class CcrLoopAscentGas extends AscentGasPlan { + CcrLoopAscentGas({required DiveEnvironment environment, + required double setpointLow, required double setpointHigh, + required double switchDepth, // > switchDepth => high setpoint + required double diluentFO2, double diluentFHe = 0.0}); + double setpointAt(double depthMeters); // depth > switchDepth ? high : low + @override AscentGas gasForDepth(double depthMeters); // effective fractions + @override List switchDepthsBetween(double deeper, double shallower); + // returns [switchDepth] when strictly crossed (setpoint change = gas change) +} +``` +`gasForDepth`: `amb = env.pressureAtDepth(d)`, `pAlv = max(amb - waterVaporPressure, 0)`, inspired = `ClosedCircuit(setpoint: setpointAt(d), diluentFO2, diluentFHe).inspiredAt(amb)`, return `AscentGas(fN2: inspired.pN2/pAlv, fHe: inspired.pHe/pAlv)` (guard pAlv<=0 → all-zero). `breakGasForDepth` stays null (loop gas is never pure O2 at stop depths under a 1.3 setpoint, and air breaks don't apply to loop deco). + +- [ ] Tests (python3-computed): effective fN2 at 40 m, SP 1.3, Tx18/45 diluent equals `pN2/pAlv` from the Task-4 Phase-1 vector (1.6412207/4.9373); fractions at 3 m (SP clamped) are ~0; `setpointAt` honors switchDepth on both sides; `switchDepthsBetween(21, 3)` with switchDepth 10 returns `[10.0]`, `(9, 3)` returns `[]`; round-trip: `calculateSegment(breathing: ClosedCircuit(...))` equals `calculateSegment(fN2: gasForDepth(d).fN2, fHe: ...)` tensions at the same constant depth (proves the equivalence the design rests on). +- [ ] Implement; deco suite green; format+commit `feat(deco): CCR loop as a depth-dependent ascent gas plan`. + +--- + +### Task 2: PlanEngine CCR mode (loading, loop deco, consumption) + +**Files:** Modify `plan_engine.dart`, `dive_plan.dart` (effective-setpoint getters); Test `test/features/planner/plan_engine_ccr_test.dart`. + +- `domain.DivePlan`: `double get effectiveSetpointLow => setpointLow ?? 0.7;` `effectiveSetpointHigh => setpointHigh ?? 1.3;` `effectiveSetpointSwitchDepth => setpointSwitchDepth ?? 10.0;` +- `PlanEngineConfig`: add `o2MetabolicRateLpm = 1.0`, `loopVolumeLiters = 6.0`. +- `PlanEngine.compute` when `plan.mode == PlanMode.ccr`: + - Segment loading breathes `ClosedCircuit(setpoint: setpointAt(segment.avgDepth), diluentFO2/FHe: segment gas)`; NDL at segment end likewise. + - Ascent plan = `CcrLoopAscentGas` (diluent = last segment's gas); schedule/TTS therefore run on the loop. + - Consumption: O2 = `o2MetabolicRateLpm × runtime-minutes(incl. deco)` charged to the `TankRole.oxygenSupply` tank when present (else uncharged); diluent = `loopVolumeLiters × (pressureAt(maxDepth) − surface)` charged to the `TankRole.diluent` tank when present, else the first segment's tank. Bailout-role tanks consume NOTHING in the main plan. OC-role SAC consumption paths skipped for ccr. + - Issues: END/density already evaluate segment gas (= diluent) ✓; add `ndlExceededNoDecoGas`-analog: when ccr, deco, and NO `TankRole.bailout` tank → reuse `ndlExceededNoDecoGas` with a bailout-specific message? NO — add enum value `noBailoutCarried` (alert). Remove the "computed as OC" doc note. +- [ ] Tests: CCR Tx18/45 60 m/25 min SP .7/1.3/10 → stops exist and total deco is LESS than the same plan computed as OC on the diluent (higher loop ppO2 shallow); O2 liters == rate×runtime within 1 L; diluent charged to diluent-role tank; bailout tank untouched; `noBailoutCarried` raised without a bailout tank and cleared with one; OC plans byte-identical to Phase 3 behavior (existing parity test still green). +- [ ] Format+commit `feat(planner): CCR loop deco schedules and consumption in PlanEngine`. + +--- + +### Task 3: Editing state + mapper carry mode/setpoints + +**Files:** Modify `plan_result.dart` (DivePlanState), `dive_planner_providers.dart` (notifier), `dive_plan_state_mapper.dart`; extend `test/features/planner/dive_plan_state_mapper_test.dart`. + +- `DivePlanState` += `PlanMode mode` (default `PlanMode.oc`), `double? setpointLow/setpointHigh/setpointSwitchDepth` (+copyWith with clear flags, props). Import `dive_plan.dart` for `PlanMode` (no cycle: dive_plan imports only plan_segment). +- Notifier: `updateMode(PlanMode)`, `updateSetpoints({double? low, double? high, double? switchDepth})` (isDirty+updatedAt as siblings do). +- Mapper: state→plan carries mode/setpoints (drop them from the `existing`-preserved set); plan→state restores them. +- [ ] Tests: mode/setpoints round-trip state→plan→state; a CCR plan loaded then saved keeps setpoints without `existing`. +- [ ] Format+commit `feat(planner): CCR mode and setpoints in the planner editing state`. + +--- + +### Task 4: Bailout solver + provider + +**Files:** Create `lib/features/planner/domain/services/bailout_solver.dart`; provider in `plan_canvas_providers.dart`; Test `test/features/planner/bailout_solver_test.dart`. + +```dart +class BailoutPoint { final int runtimeSeconds; final double depthMeters; + final int ttsSeconds; final double litersRequired; } +class BailoutOutcome { final List points; + final BailoutPoint worstCase; final double availableLiters; + bool get sufficient => worstCase.litersRequired <= availableLiters; + BailoutPoint nearest(double runtimeSeconds); } +class BailoutSolver { + const BailoutSolver({PlanEngineConfig config = const PlanEngineConfig()}); + BailoutOutcome? solve(domain.DivePlan plan); // null: not ccr / no bailout tanks / no segments +} +``` +Solve: CCR-load minute-by-minute through the user segments (depth linearly interpolated inside each segment; sample interval `max(60, totalSeconds ~/ 40)` seconds — bounded ≤ ~40 samples, plus always the exact end-of-bottom sample). At each sample: OC schedule from that depth on `OptimalOcAscentGas(bailout-role tanks, ppO2Deco)`, TTS, and liters = `sacStressedEffective × Σ(minutes × pressureAt(depth))` over travel legs (plan.ascentRate) + stops + final surfacing. `availableLiters` = Σ `gasVolume(tank)` over bailout tanks. Worst case = max liters. +Provider: `final planBailoutProvider = Provider` watching state + config (compute only when ccr). + +- [ ] Tests: square 60 m/25 min CCR plan with one AL80 EAN50 bailout → worstCase at (or within one sample of) end-of-bottom; litersRequired monotonic non-decreasing across the bottom phase; `sufficient` flips when the bailout tank shrinks (3 L) vs a pair of large tanks; returns null for OC plans and when no bailout tanks. +- [ ] Format+commit `feat(planner): worst-case bailout solver`. + +--- + +### Task 5: Canvas CCR UI + l10n + +**Files:** Modify `plan_canvas_page.dart` (badge toggle, CCR settings section in the settings sheet), `plan_results_sheet.dart` (+bailout section), `plan_canvas_chart.dart` (scrub readout appends bailout TTS); Create `lib/features/planner/presentation/widgets/ccr_settings_section.dart`; ARBs (en + 10 locales); Test `test/features/planner/ccr_ui_test.dart`. + +- Badge: `PlanChip(label: mode name uppercased)` wrapped in InkWell → `updateMode(toggled)`. +- `CcrSettingsSection` (shown in the settings sheet when ccr): three numeric `TextFormField`s (low/high in bar, switch depth in display units) writing `updateSetpoints`; own controllers in a StatefulWidget (dialog-controller-dispose rule: owned by State.dispose). +- Results sheet: when ccr and `planBailoutProvider` non-null: header `plannerCanvas_bailout_title`, rows worst-case (RT′ @ depth), bailout TTS, required vs available liters (error color + `plannerCanvas_bailout_insufficient` row when short). +- Scrub readout: when ccr and outcome non-null, append ` · BO {tts}′` from `nearest(scrubTime)` (`plannerCanvas_scrub_bailout` key). +- l10n keys (~8): `plannerCanvas_bailout_available`, `_insufficient`, `_required`, `_title`, `_tts`, `_worstCase`, `plannerCanvas_ccr_setpointHigh`, `_setpointLow`, `_switchDepth`, `plannerCanvas_scrub_bailout` — en + all 10 locales, gen-l10n zero untranslated. +- [ ] Widget tests: badge toggles state mode; CCR plan with bailout tank shows the bailout section values; insufficient case renders the error row. +- [ ] Format+commit `feat(planner): CCR canvas controls and bailout readouts`. + +--- + +### Task 6: Verification sweep + +- [ ] `flutter analyze` clean; `dart format .` stable; `flutter test test/core/deco/ test/features/planner/ test/features/dive_planner/` green; commit anything outstanding. + +**Out of scope:** per-stop manual setpoint overrides; measured-ppO2 loading; SCR planning UI; bailout contingency TABLES on the slate (Phase 7); persisted o2-rate/loop-volume settings (engine-config defaults for now). From 3ddb6750bd15943674e7f384f8fd3eeec3d1980c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 13:59:47 -0400 Subject: [PATCH 2/7] feat(deco): CCR loop as a depth-dependent ascent gas plan --- lib/core/deco/ascent/ccr_loop_ascent_gas.dart | 61 +++++++++++++ test/core/deco/ccr_loop_ascent_gas_test.dart | 90 +++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 lib/core/deco/ascent/ccr_loop_ascent_gas.dart create mode 100644 test/core/deco/ccr_loop_ascent_gas_test.dart diff --git a/lib/core/deco/ascent/ccr_loop_ascent_gas.dart b/lib/core/deco/ascent/ccr_loop_ascent_gas.dart new file mode 100644 index 000000000..d6af18345 --- /dev/null +++ b/lib/core/deco/ascent/ccr_loop_ascent_gas.dart @@ -0,0 +1,61 @@ +import 'package:submersion/core/deco/ascent/ascent_gas_plan.dart'; +import 'package:submersion/core/deco/constants/buhlmann_coefficients.dart'; +import 'package:submersion/core/deco/entities/breathing_config.dart'; +import 'package:submersion/core/deco/entities/dive_environment.dart'; + +/// The closed-circuit loop expressed as a depth-dependent ascent gas. +/// +/// At a constant depth the loop's inspired partial pressures (constant-ppO2 +/// via [ClosedCircuit]) divide by the alveolar pressure to give EXACT +/// effective inert fractions — so the unchanged Bühlmann stop-search +/// machinery (which multiplies fractions back by alveolar pressure) +/// reproduces constant-ppO2 loading at every stop. Ascent legs use the +/// fraction at the leg's deeper end, the same approximation open-circuit +/// legs already make. +class CcrLoopAscentGas extends AscentGasPlan { + CcrLoopAscentGas({ + required this.environment, + required this.setpointLow, + required this.setpointHigh, + required this.switchDepth, + required this.diluentFO2, + this.diluentFHe = 0.0, + }); + + final DiveEnvironment environment; + final double setpointLow; + final double setpointHigh; + + /// Deeper than this the loop runs [setpointHigh]; at or above it, + /// [setpointLow]. + final double switchDepth; + final double diluentFO2; + final double diluentFHe; + + /// Setpoint in force at [depthMeters]. + double setpointAt(double depthMeters) => + depthMeters > switchDepth ? setpointHigh : setpointLow; + + @override + AscentGas gasForDepth(double depthMeters) { + final ambient = environment.pressureAtDepth(depthMeters); + final pAlv = ambient - waterVaporPressure; + if (pAlv <= 0) return const AscentGas(fN2: 0, fHe: 0); + final inspired = ClosedCircuit( + setpoint: setpointAt(depthMeters), + diluentFO2: diluentFO2, + diluentFHe: diluentFHe, + ).inspiredAt(ambient); + return AscentGas(fN2: inspired.pN2 / pAlv, fHe: inspired.pHe / pAlv); + } + + @override + List switchDepthsBetween(double deeperDepth, double shallowerDepth) { + // A setpoint change is a gas change: split ascent legs at the switch. + if (switchDepth > shallowerDepth + 1e-9 && + switchDepth < deeperDepth - 1e-9) { + return [switchDepth]; + } + return const []; + } +} diff --git a/test/core/deco/ccr_loop_ascent_gas_test.dart b/test/core/deco/ccr_loop_ascent_gas_test.dart new file mode 100644 index 000000000..4dfb49ec8 --- /dev/null +++ b/test/core/deco/ccr_loop_ascent_gas_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/deco/ascent/ccr_loop_ascent_gas.dart'; +import 'package:submersion/core/deco/buhlmann_algorithm.dart'; +import 'package:submersion/core/deco/entities/breathing_config.dart'; +import 'package:submersion/core/deco/entities/dive_environment.dart'; + +CcrLoopAscentGas _loop({double switchDepth = 10.0}) => CcrLoopAscentGas( + environment: DiveEnvironment.standard, + setpointLow: 0.7, + setpointHigh: 1.3, + switchDepth: switchDepth, + diluentFO2: 0.18, + diluentFHe: 0.45, +); + +void main() { + test('effective fractions at 40 m match the python vector', () { + // python3 (Task 1): inert = (5.0-0.0627) - 1.3, split 37:45, + // divided by alveolar pressure. + final gas = _loop().gasForDepth(40.0); + expect(gas.fN2, closeTo(0.3324126003498506, 1e-9)); + expect(gas.fHe, closeTo(0.40428559502008854, 1e-9)); + }); + + test('shallow clamp: high setpoint above alveolar pressure => pure O2', () { + // With the HIGH setpoint in force at 3 m (switchDepth 0), SP 1.3 exceeds + // the alveolar pressure (1.2373 bar) and the loop clamps to pure O2. + final gas = _loop(switchDepth: 0.0).gasForDepth(3.0); + expect(gas.fN2, 0.0); + expect(gas.fHe, 0.0); + + // On the LOW setpoint (0.7 bar) at 3 m the loop still carries inert gas. + final lowSp = _loop().gasForDepth(3.0); + expect(lowSp.fN2, greaterThan(0)); + }); + + test('setpointAt honors the switch depth on both sides', () { + final loop = _loop(); + expect(loop.setpointAt(40.0), 1.3); + expect(loop.setpointAt(10.0), 0.7); // at the boundary: low + expect(loop.setpointAt(3.0), 0.7); + }); + + test('switchDepthsBetween reports the setpoint switch when crossed', () { + final loop = _loop(); + expect(loop.switchDepthsBetween(21.0, 3.0), [10.0]); + expect(loop.switchDepthsBetween(9.0, 3.0), isEmpty); + expect(loop.switchDepthsBetween(40.0, 12.0), isEmpty); + }); + + test('no break gas: air breaks never apply to the loop', () { + expect(_loop().breakGasForDepth(6.0), isNull); + }); + + test('effective fractions reproduce constant-ppO2 loading exactly', () { + // The design's load-bearing equivalence: loading a constant-depth + // segment on the effective fractions equals loading it via the + // ClosedCircuit breathing config. + final viaFractions = BuhlmannAlgorithm(gfLow: 0.5, gfHigh: 0.8); + final viaBreathing = BuhlmannAlgorithm(gfLow: 0.5, gfHigh: 0.8); + final gas = _loop().gasForDepth(40.0); + + viaFractions.calculateSegment( + depthMeters: 40, + durationSeconds: 20 * 60, + fN2: gas.fN2, + fHe: gas.fHe, + ); + viaBreathing.calculateSegment( + depthMeters: 40, + durationSeconds: 20 * 60, + breathing: const ClosedCircuit( + setpoint: 1.3, + diluentFO2: 0.18, + diluentFHe: 0.45, + ), + ); + + for (var i = 0; i < 16; i++) { + expect( + viaFractions.compartments[i].currentPN2, + closeTo(viaBreathing.compartments[i].currentPN2, 1e-12), + ); + expect( + viaFractions.compartments[i].currentPHe, + closeTo(viaBreathing.compartments[i].currentPHe, 1e-12), + ); + } + }); +} From c28ad9065bf1ce50eac13d006f8c699e90275508 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:06:18 -0400 Subject: [PATCH 3/7] feat(planner): CCR loop deco schedules and consumption in PlanEngine --- .../planner/domain/entities/dive_plan.dart | 5 + .../planner/domain/entities/plan_outcome.dart | 1 + .../planner/domain/services/plan_engine.dart | 201 +++++++++++++++--- .../widgets/plan_results_sheet.dart | 2 + lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 1 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 4 + lib/l10n/arb/app_localizations_de.dart | 4 + lib/l10n/arb/app_localizations_en.dart | 4 + lib/l10n/arb/app_localizations_es.dart | 4 + lib/l10n/arb/app_localizations_fr.dart | 4 + lib/l10n/arb/app_localizations_he.dart | 4 + lib/l10n/arb/app_localizations_hu.dart | 4 + lib/l10n/arb/app_localizations_it.dart | 4 + lib/l10n/arb/app_localizations_nl.dart | 4 + lib/l10n/arb/app_localizations_pt.dart | 4 + lib/l10n/arb/app_localizations_zh.dart | 3 + lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + .../planner/plan_engine_ccr_test.dart | 142 +++++++++++++ 28 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 test/features/planner/plan_engine_ccr_test.dart diff --git a/lib/features/planner/domain/entities/dive_plan.dart b/lib/features/planner/domain/entities/dive_plan.dart index e937879e3..72363f312 100644 --- a/lib/features/planner/domain/entities/dive_plan.dart +++ b/lib/features/planner/domain/entities/dive_plan.dart @@ -100,6 +100,11 @@ class DivePlan extends Equatable { this.tanks = const [], }); + /// CCR setpoints with the spec defaults (0.7 shallow, 1.3 below 10 m). + double get effectiveSetpointLow => setpointLow ?? 0.7; + double get effectiveSetpointHigh => setpointHigh ?? 1.3; + double get effectiveSetpointSwitchDepth => setpointSwitchDepth ?? 10.0; + /// Deco SAC: explicit value or the 0.8x-of-bottom default. double get sacDecoEffective => sacDeco ?? sacBottom * 0.8; diff --git a/lib/features/planner/domain/entities/plan_outcome.dart b/lib/features/planner/domain/entities/plan_outcome.dart index 40eb5139e..2f3c2bcbc 100644 --- a/lib/features/planner/domain/entities/plan_outcome.dart +++ b/lib/features/planner/domain/entities/plan_outcome.dart @@ -19,6 +19,7 @@ enum PlanIssueType { gasReserveViolation, gasOut, ndlExceededNoDecoGas, + noBailoutCarried, } /// One issue found while computing a plan. diff --git a/lib/features/planner/domain/services/plan_engine.dart b/lib/features/planner/domain/services/plan_engine.dart index 5075dedc8..da5af63b5 100644 --- a/lib/features/planner/domain/services/plan_engine.dart +++ b/lib/features/planner/domain/services/plan_engine.dart @@ -1,5 +1,6 @@ import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/deco/ascent/ascent_gas_plan.dart'; +import 'package:submersion/core/deco/ascent/ccr_loop_ascent_gas.dart'; import 'package:submersion/core/deco/deco_model.dart'; import 'package:submersion/core/deco/entities/breathing_config.dart'; import 'package:submersion/core/deco/entities/dive_environment.dart'; @@ -22,6 +23,12 @@ class PlanEngineConfig { final double endLimitMeters; final double otuLimit; + /// CCR metabolic O2 consumption (surface liters per minute). + final double o2MetabolicRateLpm; + + /// CCR loop + counterlung volume, for descent diluent consumption. + final double loopVolumeLiters; + const PlanEngineConfig({ this.ppO2Working = 1.4, this.ppO2Deco = 1.6, @@ -29,6 +36,8 @@ class PlanEngineConfig { this.o2Narcotic = true, this.endLimitMeters = 30.0, this.otuLimit = 300.0, + this.o2MetabolicRateLpm = 1.0, + this.loopVolumeLiters = 6.0, }); } @@ -36,14 +45,39 @@ class PlanEngineConfig { /// seams: DecoModel (BuhlmannGf), SchedulePolicy, BreathingConfig, /// DiveEnvironment. /// -/// Phase 2 computes open-circuit plans; a `mode == ccr` plan is computed on -/// its OC gases and flagged (CCR schedules arrive in Phase 4). +/// Open-circuit plans breathe their segment gases; CCR plans load at the +/// constant-ppO2 setpoints (segments' gases are the diluent) and their deco +/// schedules run on the loop via [CcrLoopAscentGas]. class PlanEngine { final PlanEngineConfig config; const PlanEngine({this.config = const PlanEngineConfig()}); + /// Setpoint in force at [depth] for a CCR plan. + double _setpointAt(domain.DivePlan plan, double depth) => + depth > plan.effectiveSetpointSwitchDepth + ? plan.effectiveSetpointHigh + : plan.effectiveSetpointLow; + + /// Mode-aware breathing for [gas] at [depth]: the loop at the in-force + /// setpoint for CCR (gas = diluent), open circuit otherwise. + BreathingConfig _breathingFor( + domain.DivePlan plan, + GasMix gas, + double depth, + ) { + if (plan.mode == domain.PlanMode.ccr) { + return ClosedCircuit( + setpoint: _setpointAt(plan, depth), + diluentFO2: gas.o2 / 100.0, + diluentFHe: gas.he / 100.0, + ); + } + return OpenCircuit(fO2: gas.o2 / 100.0, fHe: gas.he / 100.0); + } + PlanOutcome compute(domain.DivePlan plan, {TissueState? startState}) { + final isCcr = plan.mode == domain.PlanMode.ccr; final environment = DiveEnvironment.forConditions( altitudeMeters: plan.altitude, waterType: plan.waterType, @@ -68,7 +102,20 @@ class PlanEngine { final segments = List.from(plan.segments) ..sort((a, b) => a.order.compareTo(b.order)); - final ascentPlan = _ascentPlanFor(plan.tanks); + final ascentPlan = isCcr + ? CcrLoopAscentGas( + environment: environment, + setpointLow: plan.effectiveSetpointLow, + setpointHigh: plan.effectiveSetpointHigh, + switchDepth: plan.effectiveSetpointSwitchDepth, + diluentFO2: segments.isEmpty + ? 0.21 + : segments.last.gasMix.o2 / 100.0, + diluentFHe: segments.isEmpty + ? 0.0 + : segments.last.gasMix.he / 100.0, + ) + : _ascentPlanFor(plan.tanks); var state = startState ?? model.initial(); var runtime = 0; @@ -83,10 +130,7 @@ class PlanEngine { for (final segment in segments) { final startRuntime = runtime; - final breathing = OpenCircuit( - fO2: segment.gasMix.o2 / 100.0, - fHe: segment.gasMix.he / 100.0, - ); + final breathing = _breathingFor(plan, segment.gasMix, segment.avgDepth); state = model.applySegment( state, @@ -103,23 +147,35 @@ class PlanEngine { final deeperEnd = segment.startDepth > segment.endDepth ? segment.startDepth : segment.endDepth; - final segmentMaxPpO2 = O2ToxicityCalculator.calculatePpO2( - deeperEnd, - segment.gasMix.o2 / 100.0, - ); + // OC ppO2 keeps the legacy ambient x fraction convention; CCR ppO2 is + // the loop's (clamped) setpoint. + final segmentMaxPpO2 = isCcr + ? _breathingFor( + plan, + segment.gasMix, + deeperEnd, + ).inspiredAt(environment.pressureAtDepth(deeperEnd)).pO2 + : O2ToxicityCalculator.calculatePpO2( + deeperEnd, + segment.gasMix.o2 / 100.0, + ); if (segmentMaxPpO2 > maxPpO2) maxPpO2 = segmentMaxPpO2; - final avgPpO2 = O2ToxicityCalculator.calculatePpO2( - segment.avgDepth, - segment.gasMix.o2 / 100.0, - ); + final avgPpO2 = isCcr + ? breathing + .inspiredAt(environment.pressureAtDepth(segment.avgDepth)) + .pO2 + : O2ToxicityCalculator.calculatePpO2( + segment.avgDepth, + segment.gasMix.o2 / 100.0, + ); cns += o2Calc.calculateCnsForSegment(avgPpO2, segment.durationSeconds); otu += o2Calc.calculateOtuForSegment(avgPpO2, segment.durationSeconds); final ndl = model.ndlSeconds( state, depthMeters: segment.endDepth, - breathing: breathing, + breathing: _breathingFor(plan, segment.gasMix, segment.endDepth), ); final ceiling = model.ceilingMeters( state, @@ -163,14 +219,20 @@ class PlanEngine { : const DecoSchedule(stops: [], ttsSeconds: 0); final stops = _mapStops(schedule, plan, ascentPlan, lastDepth, runtime); - final tankUsages = _computeTankUsages( - plan, - segments, - stops, - lastDepth, - environment, - ascentPlan, - ); + final tankUsages = isCcr + ? _computeCcrTankUsages( + plan, + totalRuntimeSeconds: runtime + schedule.ttsSeconds, + environment: environment, + ) + : _computeTankUsages( + plan, + segments, + stops, + lastDepth, + environment, + ascentPlan, + ); final issues = _computeIssues( plan, segments, @@ -197,6 +259,76 @@ class PlanEngine { ); } + /// CCR consumption: metabolic O2 over the whole runtime charged to the + /// oxygen-supply tank, descent loop-fill diluent charged to the diluent + /// tank (else the first segment's tank). Bailout tanks consume nothing in + /// the main plan — they are sized by the bailout solver. + List _computeCcrTankUsages( + domain.DivePlan plan, { + required int totalRuntimeSeconds, + required DiveEnvironment environment, + }) { + if (plan.tanks.isEmpty) return const []; + final liters = {for (final t in plan.tanks) t.id: 0.0}; + + String? tankIdByRole(TankRole role) { + for (final tank in plan.tanks) { + if (tank.role == role) return tank.id; + } + return null; + } + + final o2TankId = tankIdByRole(TankRole.oxygenSupply); + if (o2TankId != null) { + liters[o2TankId] = + config.o2MetabolicRateLpm * (totalRuntimeSeconds / 60.0); + } + + final diluentTankId = + tankIdByRole(TankRole.diluent) ?? + (plan.segments.isNotEmpty && + liters.containsKey(plan.segments.first.tankId) + ? plan.segments.first.tankId + : null); + if (diluentTankId != null) { + liters[diluentTankId] = + (liters[diluentTankId] ?? 0) + + config.loopVolumeLiters * + (environment.pressureAtDepth(plan.maxDepth) - + environment.surfacePressureBar); + } + + return [ + for (final tank in plan.tanks) + () { + final used = liters[tank.id] ?? 0.0; + final start = tank.startPressure; + final remaining = start != null + ? pressureAfterConsuming( + tankSizeLiters: tank.volume ?? 11.0, + startPressureBar: start, + litersConsumed: used, + o2Percent: tank.gasMix.o2, + hePercent: tank.gasMix.he, + ) + : null; + return PlanTankUsage( + tankId: tank.id, + litersUsed: used, + remainingPressure: remaining, + percentUsed: start != null && start > 0 + ? (start - (remaining ?? 0)) / start * 100.0 + : 0.0, + reserveViolation: + tank.role != TankRole.bailout && + remaining != null && + used > 0 && + remaining < plan.reservePressure, + ); + }(), + ]; + } + /// SAC-based consumption: bottom SAC on descent/bottom segments, deco SAC /// on everything shallower-bound (ascent legs, stops), depth pressure via /// the plan's environment, remaining pressure compressibility-corrected. @@ -345,9 +477,12 @@ class PlanEngine { ); } - final inspiredO2 = OpenCircuit( - fO2: fO2, - fHe: fHe, + // Mode-aware: the CCR loop holds the setpoint, so hypoxia only fires + // when the in-force setpoint itself is hypoxic. + final inspiredO2 = _breathingFor( + plan, + segment.gasMix, + shallowerEnd, ).inspiredAt(environment.pressureAtDepth(shallowerEnd)).pO2; if (inspiredO2 < 0.16) { issues.add( @@ -482,7 +617,8 @@ class PlanEngine { } final inDeco = segmentOutcomes.any((s) => s.inDeco); - if (inDeco && !_hasDecoGas(plan)) { + final isCcr = plan.mode == domain.PlanMode.ccr; + if (inDeco && !isCcr && !_hasDecoGas(plan)) { issues.add( const PlanIssue( type: PlanIssueType.ndlExceededNoDecoGas, @@ -492,6 +628,15 @@ class PlanEngine { ), ); } + if (inDeco && isCcr && !plan.tanks.any((t) => t.role == TankRole.bailout)) { + issues.add( + const PlanIssue( + type: PlanIssueType.noBailoutCarried, + severity: PlanIssueSeverity.alert, + message: 'CCR decompression plan carries no bailout gas', + ), + ); + } issues.sort((a, b) => b.severity.index.compareTo(a.severity.index)); return issues; diff --git a/lib/features/planner/presentation/widgets/plan_results_sheet.dart b/lib/features/planner/presentation/widgets/plan_results_sheet.dart index d220a566a..dfaabde9b 100644 --- a/lib/features/planner/presentation/widgets/plan_results_sheet.dart +++ b/lib/features/planner/presentation/widgets/plan_results_sheet.dart @@ -61,6 +61,8 @@ String planIssueMessage( return l10n.divePlanner_warning_gasOut; case PlanIssueType.ndlExceededNoDecoGas: return l10n.plannerCanvas_issue_noDecoGas; + case PlanIssueType.noBailoutCarried: + return l10n.plannerCanvas_issue_noBailout; } } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 97d2e404f..ec918cbd7 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3240,6 +3240,7 @@ "plannerCanvas_issue_gasDensityCritical": "كثافة الغاز {value} g/L تتجاوز الحد الأقصى", "plannerCanvas_issue_gasDensityHigh": "كثافة الغاز {value} g/L تتجاوز الحد الموصى به", "plannerCanvas_issue_hypoxic": "غاز ناقص الأكسجين عند {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "خطة تخفيف الضغط CCR لا تتضمن غاز إنقاذ (bailout)", "plannerCanvas_issue_noDecoGas": "يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف", "plannerCanvas_results_noDeco": "لا يلزم تخفيف الضغط", "plannerCanvas_saved_deleteConfirmBody": "حذف \"{name}\" نهائيًا؟", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 921ff5e69..e30cb13a3 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3240,6 +3240,7 @@ "plannerCanvas_issue_gasDensityCritical": "Gasdichte {value} g/L über hartem Limit", "plannerCanvas_issue_gasDensityHigh": "Gasdichte {value} g/L über empfohlenem Limit", "plannerCanvas_issue_hypoxic": "Hypoxisches Gas bei {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "CCR-Dekompressionsplan führt kein Bailout-Gas mit", "plannerCanvas_issue_noDecoGas": "Dekompression erforderlich, aber kein Deko-Gas mitgeführt", "plannerCanvas_results_noDeco": "Keine Dekompression erforderlich", "plannerCanvas_saved_deleteConfirmBody": "„{name}“ endgültig löschen?", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 42f30a423..89ddc309f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5666,6 +5666,7 @@ "value": { "type": "String" } } }, + "plannerCanvas_issue_noBailout": "CCR decompression plan carries no bailout gas", "plannerCanvas_issue_noDecoGas": "Decompression required but no deco gas carried", "plannerCanvas_results_noDeco": "No decompression required", "plannerCanvas_saved_deleteConfirmBody": "Permanently delete \"{name}\"?", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 8e73d25f0..73240e171 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3240,6 +3240,7 @@ "plannerCanvas_issue_gasDensityCritical": "Densidad del gas {value} g/L sobre el límite máximo", "plannerCanvas_issue_gasDensityHigh": "Densidad del gas {value} g/L sobre el límite recomendado", "plannerCanvas_issue_hypoxic": "Gas hipóxico a {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "El plan de descompresión CCR no lleva gas de bailout", "plannerCanvas_issue_noDecoGas": "Se requiere descompresión pero no se lleva gas de deco", "plannerCanvas_results_noDeco": "No se requiere descompresión", "plannerCanvas_saved_deleteConfirmBody": "¿Eliminar permanentemente «{name}»?", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 1d98e2fb4..93664500c 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3206,6 +3206,7 @@ "plannerCanvas_issue_gasDensityCritical": "Densité du gaz {value} g/L au-dessus de la limite stricte", "plannerCanvas_issue_gasDensityHigh": "Densité du gaz {value} g/L au-dessus de la limite recommandée", "plannerCanvas_issue_hypoxic": "Gaz hypoxique à {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "Le plan de décompression CCR n'emporte aucun gaz de bailout", "plannerCanvas_issue_noDecoGas": "Décompression requise mais aucun gaz de déco emporté", "plannerCanvas_results_noDeco": "Aucune décompression requise", "plannerCanvas_saved_deleteConfirmBody": "Supprimer définitivement « {name} » ?", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index e3df991ca..080d004fa 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3206,6 +3206,7 @@ "plannerCanvas_issue_gasDensityCritical": "צפיפות הגז {value} g/L מעל הגבול המרבי", "plannerCanvas_issue_gasDensityHigh": "צפיפות הגז {value} g/L מעל הגבול המומלץ", "plannerCanvas_issue_hypoxic": "גז היפוקסי ב-{depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "תוכנית דקומפרסיה CCR ללא גז חילוץ (bailout)", "plannerCanvas_issue_noDecoGas": "נדרשת דקומפרסיה אך לא נלקח גז דקו", "plannerCanvas_results_noDeco": "לא נדרשת דקומפרסיה", "plannerCanvas_saved_deleteConfirmBody": "למחוק לצמיתות את \"{name}\"?", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 7b6a28cec..250ab81f1 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3206,6 +3206,7 @@ "plannerCanvas_issue_gasDensityCritical": "Gázsűrűség {value} g/L a szigorú határ felett", "plannerCanvas_issue_gasDensityHigh": "Gázsűrűség {value} g/L az ajánlott határ felett", "plannerCanvas_issue_hypoxic": "Hipoxiás gáz {depth} mélységben (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "A CCR dekompressziós terv nem tartalmaz bailout gázt", "plannerCanvas_issue_noDecoGas": "Dekompresszió szükséges, de nincs deco gáz", "plannerCanvas_results_noDeco": "Nincs szükség dekompresszióra", "plannerCanvas_saved_deleteConfirmBody": "Véglegesen törli a(z) „{name}” tervet?", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index e38c9ffd9..98e394a55 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3206,6 +3206,7 @@ "plannerCanvas_issue_gasDensityCritical": "Densità del gas {value} g/L oltre il limite massimo", "plannerCanvas_issue_gasDensityHigh": "Densità del gas {value} g/L oltre il limite consigliato", "plannerCanvas_issue_hypoxic": "Gas ipossico a {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "Il piano di decompressione CCR non prevede gas di bailout", "plannerCanvas_issue_noDecoGas": "Decompressione necessaria ma nessun gas deco a bordo", "plannerCanvas_results_noDeco": "Nessuna decompressione necessaria", "plannerCanvas_saved_deleteConfirmBody": "Eliminare definitivamente «{name}»?", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 64a3628ce..d7d63fef5 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17684,6 +17684,12 @@ abstract class AppLocalizations { /// **'Hypoxic gas at {depth} (ppO₂ {value} bar)'** String plannerCanvas_issue_hypoxic(String depth, String value); + /// No description provided for @plannerCanvas_issue_noBailout. + /// + /// In en, this message translates to: + /// **'CCR decompression plan carries no bailout gas'** + String get plannerCanvas_issue_noBailout; + /// No description provided for @plannerCanvas_issue_noDecoGas. /// /// 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 3b52f6957..2a1904613 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10194,6 +10194,10 @@ class AppLocalizationsAr extends AppLocalizations { return 'غاز ناقص الأكسجين عند $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'خطة تخفيف الضغط CCR لا تتضمن غاز إنقاذ (bailout)'; + @override String get plannerCanvas_issue_noDecoGas => 'يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index d72df86ac..2db0c30f7 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10388,6 +10388,10 @@ class AppLocalizationsDe extends AppLocalizations { return 'Hypoxisches Gas bei $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'CCR-Dekompressionsplan führt kein Bailout-Gas mit'; + @override String get plannerCanvas_issue_noDecoGas => 'Dekompression erforderlich, aber kein Deko-Gas mitgeführt'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 39b226ffb..28f083b11 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10227,6 +10227,10 @@ class AppLocalizationsEn extends AppLocalizations { return 'Hypoxic gas at $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'CCR decompression plan carries no bailout gas'; + @override String get plannerCanvas_issue_noDecoGas => 'Decompression required but no deco gas carried'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index a46542663..c9a0d2f63 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10376,6 +10376,10 @@ class AppLocalizationsEs extends AppLocalizations { return 'Gas hipóxico a $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'El plan de descompresión CCR no lleva gas de bailout'; + @override String get plannerCanvas_issue_noDecoGas => 'Se requiere descompresión pero no se lleva gas de deco'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 70a113107..b1f700c84 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10426,6 +10426,10 @@ class AppLocalizationsFr extends AppLocalizations { return 'Gaz hypoxique à $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'Le plan de décompression CCR n\'emporte aucun gaz de bailout'; + @override String get plannerCanvas_issue_noDecoGas => 'Décompression requise mais aucun gaz de déco emporté'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index e132a8a2c..7423a99fc 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10127,6 +10127,10 @@ class AppLocalizationsHe extends AppLocalizations { return 'גז היפוקסי ב-$depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'תוכנית דקומפרסיה CCR ללא גז חילוץ (bailout)'; + @override String get plannerCanvas_issue_noDecoGas => 'נדרשת דקומפרסיה אך לא נלקח גז דקו'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index f65245223..332649d43 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10364,6 +10364,10 @@ class AppLocalizationsHu extends AppLocalizations { return 'Hipoxiás gáz $depth mélységben (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'A CCR dekompressziós terv nem tartalmaz bailout gázt'; + @override String get plannerCanvas_issue_noDecoGas => 'Dekompresszió szükséges, de nincs deco gáz'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 6cb4ba2cf..1fdbfcff7 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10392,6 +10392,10 @@ class AppLocalizationsIt extends AppLocalizations { return 'Gas ipossico a $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'Il piano di decompressione CCR non prevede gas di bailout'; + @override String get plannerCanvas_issue_noDecoGas => 'Decompressione necessaria ma nessun gas deco a bordo'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 3ee9964c3..27d06d363 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10315,6 +10315,10 @@ class AppLocalizationsNl extends AppLocalizations { return 'Hypoxisch gas op $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'CCR-decompressieplan heeft geen bailoutgas aan boord'; + @override String get plannerCanvas_issue_noDecoGas => 'Decompressie vereist maar geen decogas meegenomen'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 19f3d5958..4f1dcabbd 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10397,6 +10397,10 @@ class AppLocalizationsPt extends AppLocalizations { return 'Gás hipóxico a $depth (ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => + 'O plano de descompressão CCR não leva gás de bailout'; + @override String get plannerCanvas_issue_noDecoGas => 'Descompressão necessária mas sem gás de deco a bordo'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 18526e426..095bbf577 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9942,6 +9942,9 @@ class AppLocalizationsZh extends AppLocalizations { return '$depth 处为低氧气体(ppO₂ $value bar)'; } + @override + String get plannerCanvas_issue_noBailout => 'CCR 减压计划未携带逃生气体'; + @override String get plannerCanvas_issue_noDecoGas => '需要减压但未携带减压气体'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 64548d3d6..4f64eec43 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3240,6 +3240,7 @@ "plannerCanvas_issue_gasDensityCritical": "Gasdichtheid {value} g/L boven harde limiet", "plannerCanvas_issue_gasDensityHigh": "Gasdichtheid {value} g/L boven aanbevolen limiet", "plannerCanvas_issue_hypoxic": "Hypoxisch gas op {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "CCR-decompressieplan heeft geen bailoutgas aan boord", "plannerCanvas_issue_noDecoGas": "Decompressie vereist maar geen decogas meegenomen", "plannerCanvas_results_noDeco": "Geen decompressie vereist", "plannerCanvas_saved_deleteConfirmBody": "'{name}' definitief verwijderen?", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index c8a92529d..0d9f14b29 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3240,6 +3240,7 @@ "plannerCanvas_issue_gasDensityCritical": "Densidade do gás {value} g/L acima do limite máximo", "plannerCanvas_issue_gasDensityHigh": "Densidade do gás {value} g/L acima do limite recomendado", "plannerCanvas_issue_hypoxic": "Gás hipóxico a {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "O plano de descompressão CCR não leva gás de bailout", "plannerCanvas_issue_noDecoGas": "Descompressão necessária mas sem gás de deco a bordo", "plannerCanvas_results_noDeco": "Nenhuma descompressão necessária", "plannerCanvas_saved_deleteConfirmBody": "Excluir permanentemente \"{name}\"?", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 597105df4..8ae02693e 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3390,6 +3390,7 @@ "plannerCanvas_issue_gasDensityCritical": "气体密度 {value} g/L 超过硬性上限", "plannerCanvas_issue_gasDensityHigh": "气体密度 {value} g/L 超过建议上限", "plannerCanvas_issue_hypoxic": "{depth} 处为低氧气体(ppO₂ {value} bar)", + "plannerCanvas_issue_noBailout": "CCR 减压计划未携带逃生气体", "plannerCanvas_issue_noDecoGas": "需要减压但未携带减压气体", "plannerCanvas_results_noDeco": "无需减压", "plannerCanvas_saved_deleteConfirmBody": "永久删除“{name}”?", diff --git a/test/features/planner/plan_engine_ccr_test.dart b/test/features/planner/plan_engine_ccr_test.dart new file mode 100644 index 000000000..6950a7b61 --- /dev/null +++ b/test/features/planner/plan_engine_ccr_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; + +const _diluent = GasMix(o2: 18, he: 45); +const _diluentTank = DiveTank( + id: 'dil', + volume: 3.0, + startPressure: 200, + gasMix: _diluent, + role: TankRole.diluent, +); +const _o2Tank = DiveTank( + id: 'o2', + volume: 3.0, + startPressure: 200, + gasMix: GasMix(o2: 100), + role: TankRole.oxygenSupply, +); +const _bailoutTank = DiveTank( + id: 'bo', + volume: 11.1, + startPressure: 207, + gasMix: GasMix(o2: 50), + role: TankRole.bailout, +); + +List _segments({double depth = 60.0, int minutes = 25}) => [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: depth, + tankId: 'dil', + gasMix: _diluent, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: depth, + durationMinutes: minutes, + tankId: 'dil', + gasMix: _diluent, + order: 1, + ), +]; + +domain.DivePlan _plan({ + domain.PlanMode mode = domain.PlanMode.ccr, + List tanks = const [_diluentTank, _o2Tank, _bailoutTank], +}) { + return domain.DivePlan( + id: 'plan-1', + name: 'CCR test', + mode: mode, + gfLow: 50, + gfHigh: 80, + tanks: tanks, + segments: _segments(), + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +void main() { + const engine = PlanEngine(); + + group('PlanEngine CCR', () { + test('loop deco is shorter than OC deco on the diluent alone', () { + final ccr = engine.compute(_plan()); + // The honest OC baseline carries ONLY the diluent: giving the OC plan + // the O2/EAN50 cylinders would hand it deco gases the loop's low + // shallow setpoint (0.7 bar) cannot match. + final ocDiluentOnly = engine.compute( + _plan(mode: domain.PlanMode.oc, tanks: const [_diluentTank]), + ); + + expect(ccr.stops, isNotEmpty); + expect( + ccr.totalDecoSeconds, + lessThan(ocDiluentOnly.totalDecoSeconds), + reason: 'constant-ppO2 loop off-gasses faster than OC diluent', + ); + }); + + test('O2 consumption is metabolic rate times runtime', () { + final outcome = engine.compute(_plan()); + final o2 = outcome.tankUsages.firstWhere((u) => u.tankId == 'o2'); + expect(o2.litersUsed, closeTo(1.0 * outcome.runtimeSeconds / 60.0, 1.0)); + }); + + test('diluent charged for the descent loop fill; bailout untouched', () { + final outcome = engine.compute(_plan()); + final diluent = outcome.tankUsages.firstWhere((u) => u.tankId == 'dil'); + // 6 L loop x 6 bar depth-pressure delta at 60 m standard. + expect(diluent.litersUsed, closeTo(36.0, 0.5)); + final bailout = outcome.tankUsages.firstWhere((u) => u.tankId == 'bo'); + expect(bailout.litersUsed, 0.0); + expect(bailout.reserveViolation, isFalse); + }); + + test('CCR ppO2 and CNS run on the setpoint, not the diluent', () { + final outcome = engine.compute(_plan()); + // At 1.3 bar setpoint no ppO2 issue should fire even at 60 m, where + // OC on the diluent would be far below any limit anyway; the real + // check: max segment ppO2 equals the high setpoint. + final bottom = outcome.segmentOutcomes.last; + expect(bottom.maxPpO2, closeTo(1.3, 1e-9)); + expect( + outcome.issues.map((i) => i.type), + isNot(contains(PlanIssueType.ppO2Critical)), + ); + }); + + test( + 'noBailoutCarried fires without a bailout tank and clears with one', + () { + final without = engine.compute( + _plan(tanks: const [_diluentTank, _o2Tank]), + ); + expect( + without.issues.map((i) => i.type), + contains(PlanIssueType.noBailoutCarried), + ); + // OC-specific "no deco gas" alert must NOT fire for CCR. + expect( + without.issues.map((i) => i.type), + isNot(contains(PlanIssueType.ndlExceededNoDecoGas)), + ); + + final withBailout = engine.compute(_plan()); + expect( + withBailout.issues.map((i) => i.type), + isNot(contains(PlanIssueType.noBailoutCarried)), + ); + }, + ); + }); +} From 1c70ff4fa78c8375e72e3af788a8d8270d86ad94 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:09:06 -0400 Subject: [PATCH 4/7] feat(planner): CCR mode and setpoints in the planner editing state --- .../domain/entities/plan_result.dart | 31 +++++++++++++++++++ .../providers/dive_planner_providers.dart | 20 ++++++++++++ .../services/dive_plan_state_mapper.dart | 23 ++++++++++---- .../planner/dive_plan_state_mapper_test.dart | 26 ++++++++++++++-- 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/lib/features/dive_planner/domain/entities/plan_result.dart b/lib/features/dive_planner/domain/entities/plan_result.dart index c58167b5d..4d2b2b2a1 100644 --- a/lib/features/dive_planner/domain/entities/plan_result.dart +++ b/lib/features/dive_planner/domain/entities/plan_result.dart @@ -3,6 +3,8 @@ import 'package:equatable/equatable.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' + show PlanMode; /// Types of warnings that can occur during dive planning. enum PlanWarningType { @@ -497,6 +499,16 @@ class DivePlanState extends Equatable { /// Altitude above sea level in meters (for altitude diving). final double? altitude; + /// Breathing mode (open circuit or CCR). + final PlanMode mode; + + /// CCR setpoints in bar; null = the engine's defaults (0.7 / 1.3). + final double? setpointLow; + final double? setpointHigh; + + /// Depth below which the high setpoint is in force; null = default 10 m. + final double? setpointSwitchDepth; + /// Reserve pressure in bar. final double reservePressure; @@ -524,6 +536,10 @@ class DivePlanState extends Equatable { this.initialTissueState, this.siteId, this.altitude, + this.mode = PlanMode.oc, + this.setpointLow, + this.setpointHigh, + this.setpointSwitchDepth, this.reservePressure = kDefaultReservePressureBar, this.notes = '', this.isDirty = false, @@ -571,6 +587,10 @@ class DivePlanState extends Equatable { List? initialTissueState, String? siteId, double? altitude, + PlanMode? mode, + double? setpointLow, + double? setpointHigh, + double? setpointSwitchDepth, double? reservePressure, String? notes, bool? isDirty, @@ -580,6 +600,7 @@ class DivePlanState extends Equatable { bool clearInitialTissueState = false, bool clearSiteId = false, bool clearAltitude = false, + bool clearSetpoints = false, }) { return DivePlanState( id: id ?? this.id, @@ -597,6 +618,12 @@ class DivePlanState extends Equatable { : (initialTissueState ?? this.initialTissueState), siteId: clearSiteId ? null : (siteId ?? this.siteId), altitude: clearAltitude ? null : (altitude ?? this.altitude), + mode: mode ?? this.mode, + setpointLow: clearSetpoints ? null : (setpointLow ?? this.setpointLow), + setpointHigh: clearSetpoints ? null : (setpointHigh ?? this.setpointHigh), + setpointSwitchDepth: clearSetpoints + ? null + : (setpointSwitchDepth ?? this.setpointSwitchDepth), reservePressure: reservePressure ?? this.reservePressure, notes: notes ?? this.notes, isDirty: isDirty ?? this.isDirty, @@ -618,6 +645,10 @@ class DivePlanState extends Equatable { initialTissueState, siteId, altitude, + mode, + setpointLow, + setpointHigh, + setpointSwitchDepth, reservePressure, notes, isDirty, 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 4465c06d8..b5b3b211e 100644 --- a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart +++ b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart @@ -341,6 +341,26 @@ class DivePlanNotifier extends StateNotifier { ); } + /// Switch between open circuit and CCR. + void updateMode(domain.PlanMode mode) { + state = state.copyWith( + mode: mode, + isDirty: true, + updatedAt: DateTime.now(), + ); + } + + /// Update CCR setpoints; only supplied values change. + void updateSetpoints({double? low, double? high, double? switchDepth}) { + state = state.copyWith( + setpointLow: low, + setpointHigh: high, + setpointSwitchDepth: switchDepth, + isDirty: true, + updatedAt: DateTime.now(), + ); + } + // -------------------------------------------------------------------------- // Repetitive Dive Support // -------------------------------------------------------------------------- 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 5ec5a0b83..190b5f307 100644 --- a/lib/features/planner/domain/services/dive_plan_state_mapper.dart +++ b/lib/features/planner/domain/services/dive_plan_state_mapper.dart @@ -2,13 +2,13 @@ import 'package:submersion/features/dive_planner/domain/entities/plan_result.dar import 'package:submersion/features/planner/domain/entities/dive_plan.dart' as domain; -/// Maps between the legacy planner UI state ([DivePlanState]) and the -/// persisted [domain.DivePlan] aggregate. +/// Maps between the planner UI state ([DivePlanState]) and the persisted +/// [domain.DivePlan] aggregate. /// -/// The legacy state carries a subset of the aggregate; [existing] preserves -/// fields the state does not know about (mode, rates, CCR/contingency -/// config, water type, dive links) across an edit-save cycle so a plan -/// touched by the old UI does not lose them. +/// 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. domain.DivePlan divePlanFromState( DivePlanState state, { domain.DivePlan? existing, @@ -31,6 +31,13 @@ domain.DivePlan divePlanFromState( clearSiteId: state.siteId == null, altitude: state.altitude, clearAltitude: state.altitude == null, + mode: state.mode, + setpointLow: state.setpointLow, + clearSetpointLow: state.setpointLow == null, + setpointHigh: state.setpointHigh, + clearSetpointHigh: state.setpointHigh == null, + setpointSwitchDepth: state.setpointSwitchDepth, + clearSetpointSwitchDepth: state.setpointSwitchDepth == null, gfLow: state.gfLow, gfHigh: state.gfHigh, sacBottom: state.sacRate, @@ -52,6 +59,10 @@ DivePlanState stateFromDivePlan(domain.DivePlan plan) { notes: plan.notes, siteId: plan.siteId, altitude: plan.altitude, + mode: plan.mode, + setpointLow: plan.setpointLow, + setpointHigh: plan.setpointHigh, + setpointSwitchDepth: plan.setpointSwitchDepth, gfLow: plan.gfLow, gfHigh: plan.gfHigh, sacRate: plan.sacBottom, diff --git a/test/features/planner/dive_plan_state_mapper_test.dart b/test/features/planner/dive_plan_state_mapper_test.dart index 3292c6c47..4a3c2f475 100644 --- a/test/features/planner/dive_plan_state_mapper_test.dart +++ b/test/features/planner/dive_plan_state_mapper_test.dart @@ -77,20 +77,40 @@ void main() { ); final merged = divePlanFromState(state(), existing: existing); - // Legacy-owned fields come from the state... + // State-owned fields come from the state (mode/setpoints joined that + // set in Phase 4: the state defaults to OC with null setpoints)... expect(merged.name, 'Reef dive'); expect(merged.gfLow, 45); expect(merged.sacBottom, 17.0); + expect(merged.mode, domain.PlanMode.oc); + expect(merged.setpointHigh, isNull); // ...while aggregate-only fields survive the cycle. - expect(merged.mode, domain.PlanMode.ccr); expect(merged.waterType, WaterType.fresh); expect(merged.descentRate, 20.0); expect(merged.airBreaks, isNotNull); - expect(merged.setpointHigh, 1.3); expect(merged.turnPressureRule, domain.TurnPressureRule.thirds); expect(merged.sourceDiveId, 'dive-9'); }); + test('mode and setpoints round-trip through the state', () { + final ccrState = state().copyWith( + mode: domain.PlanMode.ccr, + setpointLow: 0.7, + setpointHigh: 1.4, + setpointSwitchDepth: 12.0, + ); + final plan = divePlanFromState(ccrState); + expect(plan.mode, domain.PlanMode.ccr); + expect(plan.setpointHigh, 1.4); + expect(plan.setpointSwitchDepth, 12.0); + + final restored = stateFromDivePlan(plan); + expect(restored.mode, domain.PlanMode.ccr); + expect(restored.setpointLow, 0.7); + expect(restored.setpointHigh, 1.4); + expect(restored.setpointSwitchDepth, 12.0); + }); + test('null state fields clear stale values from the existing plan', () { final existing = domain.DivePlan( id: 'p1', From d69eb89f5ae8ddc0e3a60ccc7e68e989751d219c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:11:01 -0400 Subject: [PATCH 5/7] feat(planner): worst-case bailout solver --- .../domain/services/bailout_solver.dart | 231 ++++++++++++++++++ .../providers/plan_canvas_providers.dart | 9 + .../features/planner/bailout_solver_test.dart | 113 +++++++++ 3 files changed, 353 insertions(+) create mode 100644 lib/features/planner/domain/services/bailout_solver.dart create mode 100644 test/features/planner/bailout_solver_test.dart diff --git a/lib/features/planner/domain/services/bailout_solver.dart b/lib/features/planner/domain/services/bailout_solver.dart new file mode 100644 index 000000000..c69c315ab --- /dev/null +++ b/lib/features/planner/domain/services/bailout_solver.dart @@ -0,0 +1,231 @@ +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/ascent/ascent_gas_plan.dart'; +import 'package:submersion/core/deco/deco_model.dart'; +import 'package:submersion/core/deco/entities/breathing_config.dart'; +import 'package:submersion/core/deco/entities/dive_environment.dart'; +import 'package:submersion/core/deco/o2_toxicity_calculator.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/core/utils/gas_compressibility.dart'; +import 'package:submersion/features/dive_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'; + +/// The open-circuit bailout picture at one instant of a CCR plan. +class BailoutPoint { + final int runtimeSeconds; + final double depthMeters; + final int ttsSeconds; + final double litersRequired; + + const BailoutPoint({ + required this.runtimeSeconds, + required this.depthMeters, + required this.ttsSeconds, + required this.litersRequired, + }); +} + +/// Bailout demand sampled along a CCR plan, with the worst-case instant. +class BailoutOutcome { + final List points; + final BailoutPoint worstCase; + + /// Surface liters actually carried in bailout-role tanks + /// (compressibility-corrected). + final double availableLiters; + + const BailoutOutcome({ + required this.points, + required this.worstCase, + required this.availableLiters, + }); + + bool get sufficient => worstCase.litersRequired <= availableLiters; + + /// The sampled point closest in runtime to [runtimeSeconds]. + BailoutPoint nearest(double runtimeSeconds) { + var best = points.first; + var bestDelta = (best.runtimeSeconds - runtimeSeconds).abs(); + for (final point in points) { + final delta = (point.runtimeSeconds - runtimeSeconds).abs(); + if (delta < bestDelta) { + best = point; + bestDelta = delta; + } + } + return best; + } +} + +/// Answers "what if the loop dies HERE": walks the CCR plan's bottom phase, +/// and at bounded sample intervals computes the full open-circuit bailout +/// (schedule on the bailout gases, stressed SAC) from that instant. The +/// worst case sizes the bailout cylinders. +class BailoutSolver { + final PlanEngineConfig config; + + const BailoutSolver({this.config = const PlanEngineConfig()}); + + BailoutOutcome? solve(domain.DivePlan plan) { + if (plan.mode != domain.PlanMode.ccr) return null; + final bailoutTanks = plan.tanks + .where((t) => t.role == TankRole.bailout) + .toList(); + if (bailoutTanks.isEmpty) return null; + final segments = List.from(plan.segments) + ..sort((a, b) => a.order.compareTo(b.order)); + if (segments.isEmpty) return null; + + final environment = DiveEnvironment.forConditions( + altitudeMeters: plan.altitude, + waterType: plan.waterType, + ); + final policy = SchedulePolicy( + lastStopDepth: plan.lastStopDepth, + ascentRate: plan.ascentRate, + gasSwitchStopSeconds: plan.gasSwitchStopSeconds, + airBreaks: plan.airBreaks, + ); + final model = BuhlmannGf( + gfLow: plan.gfLow / 100.0, + gfHigh: plan.gfHigh / 100.0, + environment: environment, + policy: policy, + ); + final bailoutPlan = OptimalOcAscentGas( + maxPpO2: config.ppO2Deco, + gases: [ + for (final tank in bailoutTanks) + AvailableGas( + fN2: (100.0 - tank.gasMix.o2 - tank.gasMix.he) / 100.0, + fHe: tank.gasMix.he / 100.0, + maxPpO2Mod: O2ToxicityCalculator.calculateMod( + tank.gasMix.o2 / 100.0, + maxPpO2: config.ppO2Deco, + ), + ), + ], + ); + + final availableLiters = bailoutTanks.fold( + 0, + (sum, tank) => + sum + + gasVolume( + tankSizeLiters: tank.volume ?? 11.0, + pressureBar: tank.startPressure ?? 0, + o2Percent: tank.gasMix.o2, + hePercent: tank.gasMix.he, + ), + ); + + final totalSeconds = segments.fold( + 0, + (sum, s) => sum + s.durationSeconds, + ); + final sampleInterval = totalSeconds > 60 * 40 ? totalSeconds ~/ 40 : 60; + + var state = model.initial(); + var elapsed = 0; + final points = []; + + for (final segment in segments) { + var covered = 0; + while (covered < segment.durationSeconds) { + final chunk = (segment.durationSeconds - covered) < sampleInterval + ? segment.durationSeconds - covered + : sampleInterval; + double depthAt(int secondsIntoSegment) => + segment.startDepth + + (segment.endDepth - segment.startDepth) * + (secondsIntoSegment / segment.durationSeconds); + final chunkStartDepth = depthAt(covered); + final chunkEndDepth = depthAt(covered + chunk); + final chunkAvg = (chunkStartDepth + chunkEndDepth) / 2.0; + + state = model.applySegment( + state, + DecoSegment( + startDepth: chunkStartDepth, + endDepth: chunkEndDepth, + durationSeconds: chunk, + ), + ClosedCircuit( + setpoint: chunkAvg > plan.effectiveSetpointSwitchDepth + ? plan.effectiveSetpointHigh + : plan.effectiveSetpointLow, + diluentFO2: segment.gasMix.o2 / 100.0, + diluentFHe: segment.gasMix.he / 100.0, + ), + ); + covered += chunk; + elapsed += chunk; + + if (chunkEndDepth > 0) { + final schedule = model.schedule( + state, + currentDepth: chunkEndDepth, + gases: bailoutPlan, + ); + points.add( + BailoutPoint( + runtimeSeconds: elapsed, + depthMeters: chunkEndDepth, + ttsSeconds: schedule.ttsSeconds, + litersRequired: _ascentLiters( + schedule, + fromDepth: chunkEndDepth, + sac: plan.sacStressedEffective, + ascentRate: plan.ascentRate, + environment: environment, + ), + ), + ); + } + } + } + + if (points.isEmpty) return null; + var worst = points.first; + for (final point in points) { + if (point.litersRequired > worst.litersRequired) worst = point; + } + return BailoutOutcome( + points: points, + worstCase: worst, + availableLiters: availableLiters, + ); + } + + /// Stressed-SAC surface liters for an OC ascent: travel legs at + /// [ascentRate], the stops themselves, and the final surfacing leg. + double _ascentLiters( + DecoSchedule schedule, { + required double fromDepth, + required double sac, + required double ascentRate, + required DiveEnvironment environment, + }) { + var liters = 0.0; + var depth = fromDepth; + for (final stop in schedule.stops) { + final legSeconds = ((depth - stop.depthMeters) / ascentRate * 60).round(); + liters += + sac * + (legSeconds / 60.0) * + environment.pressureAtDepth((depth + stop.depthMeters) / 2.0); + liters += + sac * + (stop.durationSeconds / 60.0) * + environment.pressureAtDepth(stop.depthMeters); + depth = stop.depthMeters; + } + if (depth > 0) { + final legSeconds = (depth / ascentRate * 60).round(); + liters += + sac * (legSeconds / 60.0) * environment.pressureAtDepth(depth / 2.0); + } + return liters; + } +} diff --git a/lib/features/planner/presentation/providers/plan_canvas_providers.dart b/lib/features/planner/presentation/providers/plan_canvas_providers.dart index 2d057f1de..c90e7ffdc 100644 --- a/lib/features/planner/presentation/providers/plan_canvas_providers.dart +++ b/lib/features/planner/presentation/providers/plan_canvas_providers.dart @@ -3,6 +3,7 @@ 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/dive_planner/presentation/providers/dive_planner_providers.dart'; import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; +import 'package:submersion/features/planner/domain/services/bailout_solver.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/settings/presentation/providers/settings_providers.dart'; @@ -191,3 +192,11 @@ final planCanvasSeriesProvider = Provider((ref) { maxDepth: outcome.maxDepth, ); }); + +/// Worst-case bailout for the current CCR plan; null for OC plans or when +/// no bailout-role tank is carried. Recomputed live with the plan. +final planBailoutProvider = Provider((ref) { + final state = ref.watch(divePlanNotifierProvider); + final solver = BailoutSolver(config: ref.watch(planEngineConfigProvider)); + return solver.solve(divePlanFromState(state)); +}); diff --git a/test/features/planner/bailout_solver_test.dart b/test/features/planner/bailout_solver_test.dart new file mode 100644 index 000000000..1675c8ccd --- /dev/null +++ b/test/features/planner/bailout_solver_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.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/bailout_solver.dart'; + +const _diluent = GasMix(o2: 18, he: 45); +const _diluentTank = DiveTank( + id: 'dil', + volume: 3.0, + startPressure: 200, + gasMix: _diluent, + role: TankRole.diluent, +); + +DiveTank _bailout({double volume = 11.1, double pressure = 207}) => DiveTank( + id: 'bo', + volume: volume, + startPressure: pressure, + gasMix: const GasMix(o2: 50), + role: TankRole.bailout, +); + +domain.DivePlan _plan({ + domain.PlanMode mode = domain.PlanMode.ccr, + List? tanks, + int bottomMinutes = 25, +}) { + return domain.DivePlan( + id: 'plan-1', + name: 'Bailout test', + mode: mode, + gfLow: 50, + gfHigh: 80, + tanks: tanks ?? [_diluentTank, _bailout()], + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: 60.0, + tankId: 'dil', + gasMix: _diluent, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: 60.0, + durationMinutes: bottomMinutes, + tankId: 'dil', + gasMix: _diluent, + order: 1, + ), + ], + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +void main() { + const solver = BailoutSolver(); + + test('returns null for OC plans and when no bailout tanks are carried', () { + expect(solver.solve(_plan(mode: domain.PlanMode.oc)), isNull); + expect(solver.solve(_plan(tanks: const [_diluentTank])), isNull); + }); + + test('worst case sits at (or near) the end of the bottom phase', () { + final outcome = solver.solve(_plan())!; + final lastPoint = outcome.points.last; + expect( + outcome.worstCase.runtimeSeconds, + greaterThanOrEqualTo(lastPoint.runtimeSeconds - 90), + reason: 'square profile loads monotonically, so latest is worst', + ); + expect(outcome.worstCase.depthMeters, closeTo(60.0, 0.1)); + }); + + test('required liters grow monotonically across the bottom phase', () { + final outcome = solver.solve(_plan())!; + final bottomPoints = outcome.points + .where((p) => p.depthMeters > 59.0) + .toList(); + expect(bottomPoints.length, greaterThan(2)); + for (var i = 1; i < bottomPoints.length; i++) { + expect( + bottomPoints[i].litersRequired, + greaterThanOrEqualTo(bottomPoints[i - 1].litersRequired - 1e-6), + ); + } + }); + + test('sufficiency flips with the carried bailout volume', () { + final small = solver.solve( + _plan(tanks: [_diluentTank, _bailout(volume: 3.0, pressure: 100)]), + )!; + expect(small.sufficient, isFalse); + + final big = solver.solve( + _plan( + tanks: [_diluentTank, _bailout(volume: 24.0, pressure: 232)], + bottomMinutes: 10, + ), + )!; + expect(big.sufficient, isTrue); + }); + + test('nearest() returns the closest sampled point', () { + final outcome = solver.solve(_plan())!; + final target = outcome.points[2].runtimeSeconds.toDouble(); + expect(outcome.nearest(target).runtimeSeconds, target.toInt()); + }); +} From 419aeb84a961d6c8032f983031c8ce39e12f00e4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 14:16:40 -0400 Subject: [PATCH 6/7] feat(planner): CCR canvas controls and bailout readouts --- .../presentation/pages/plan_canvas_page.dart | 69 ++++++++--- .../widgets/ccr_settings_section.dart | 111 ++++++++++++++++++ .../widgets/plan_canvas_chart.dart | 22 +++- .../widgets/plan_results_sheet.dart | 82 +++++++++++++ lib/l10n/arb/app_ar.arb | 10 ++ lib/l10n/arb/app_de.arb | 10 ++ lib/l10n/arb/app_en.arb | 36 ++++++ lib/l10n/arb/app_es.arb | 10 ++ lib/l10n/arb/app_fr.arb | 10 ++ lib/l10n/arb/app_he.arb | 10 ++ lib/l10n/arb/app_hu.arb | 10 ++ lib/l10n/arb/app_it.arb | 10 ++ lib/l10n/arb/app_localizations.dart | 60 ++++++++++ lib/l10n/arb/app_localizations_ar.dart | 41 +++++++ lib/l10n/arb/app_localizations_de.dart | 41 +++++++ lib/l10n/arb/app_localizations_en.dart | 41 +++++++ lib/l10n/arb/app_localizations_es.dart | 42 +++++++ lib/l10n/arb/app_localizations_fr.dart | 42 +++++++ lib/l10n/arb/app_localizations_he.dart | 41 +++++++ lib/l10n/arb/app_localizations_hu.dart | 41 +++++++ lib/l10n/arb/app_localizations_it.dart | 41 +++++++ lib/l10n/arb/app_localizations_nl.dart | 41 +++++++ lib/l10n/arb/app_localizations_pt.dart | 42 +++++++ lib/l10n/arb/app_localizations_zh.dart | 40 +++++++ lib/l10n/arb/app_nl.arb | 10 ++ lib/l10n/arb/app_pt.arb | 10 ++ lib/l10n/arb/app_zh.arb | 10 ++ test/features/planner/ccr_ui_test.dart | 100 ++++++++++++++++ 28 files changed, 1008 insertions(+), 25 deletions(-) create mode 100644 lib/features/planner/presentation/widgets/ccr_settings_section.dart create mode 100644 test/features/planner/ccr_ui_test.dart diff --git a/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart index 3c09e4e6c..37b69dbb0 100644 --- a/lib/features/planner/presentation/pages/plan_canvas_page.dart +++ b/lib/features/planner/presentation/pages/plan_canvas_page.dart @@ -7,7 +7,10 @@ import 'package:submersion/features/dive_planner/presentation/widgets/plan_tank_ import 'package:submersion/features/dive_planner/presentation/widgets/segment_list.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/simple_plan_dialog.dart'; import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; +import 'package:submersion/features/planner/presentation/widgets/ccr_settings_section.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'; @@ -57,16 +60,31 @@ class _PlanCanvasPageState extends ConsumerState { return Scaffold( appBar: AppBar( - title: InkWell( - onTap: () => _showRenameDialog(context), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: Text(planState.name)), - const SizedBox(width: 8), - const PlanChip(label: 'OC'), - ], - ), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: InkWell( + onTap: () => _showRenameDialog(context), + child: Text(planState.name), + ), + ), + const SizedBox(width: 8), + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => ref + .read(divePlanNotifierProvider.notifier) + .updateMode( + planState.mode == domain.PlanMode.ccr + ? domain.PlanMode.oc + : domain.PlanMode.ccr, + ), + child: PlanChip( + label: planState.mode == domain.PlanMode.ccr ? 'CCR' : 'OC', + emphasized: planState.mode == domain.PlanMode.ccr, + ), + ), + ], ), actions: [ IconButton( @@ -209,12 +227,15 @@ class _PlanCanvasPageState extends ConsumerState { width: 360, child: ListView( padding: const EdgeInsets.all(12), - children: const [ - PlanSettingsPanel(), - SizedBox(height: 12), - PlanTankList(), - SizedBox(height: 12), - SegmentList(), + children: [ + const PlanSettingsPanel(), + if (ref.watch(divePlanNotifierProvider).mode == + domain.PlanMode.ccr) + const CcrSettingsSection(), + const SizedBox(height: 12), + const PlanTankList(), + const SizedBox(height: 12), + const SegmentList(), ], ), ), @@ -284,10 +305,20 @@ class _PlanCanvasPageState extends ConsumerState { context: context, showDragHandle: true, isScrollControlled: true, - builder: (_) => const SafeArea( + builder: (_) => SafeArea( child: SingleChildScrollView( - padding: EdgeInsets.all(16), - child: PlanSettingsPanel(), + padding: const EdgeInsets.all(16), + child: Consumer( + builder: (context, ref, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const PlanSettingsPanel(), + if (ref.watch(divePlanNotifierProvider).mode == + domain.PlanMode.ccr) + const CcrSettingsSection(), + ], + ), + ), ), ), ); diff --git a/lib/features/planner/presentation/widgets/ccr_settings_section.dart b/lib/features/planner/presentation/widgets/ccr_settings_section.dart new file mode 100644 index 000000000..0a5808f90 --- /dev/null +++ b/lib/features/planner/presentation/widgets/ccr_settings_section.dart @@ -0,0 +1,111 @@ +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_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Setpoint controls for a CCR plan: low/high setpoints (bar) and the +/// depth below which the high setpoint is in force (display units). +class CcrSettingsSection extends ConsumerStatefulWidget { + const CcrSettingsSection({super.key}); + + @override + ConsumerState createState() => _CcrSettingsSectionState(); +} + +class _CcrSettingsSectionState extends ConsumerState { + late final TextEditingController _lowController; + late final TextEditingController _highController; + late final TextEditingController _switchController; + + @override + void initState() { + super.initState(); + final state = ref.read(divePlanNotifierProvider); + final units = UnitFormatter(ref.read(settingsProvider)); + _lowController = TextEditingController( + text: (state.setpointLow ?? 0.7).toStringAsFixed(1), + ); + _highController = TextEditingController( + text: (state.setpointHigh ?? 1.3).toStringAsFixed(1), + ); + _switchController = TextEditingController( + text: units + .convertDepth(state.setpointSwitchDepth ?? 10.0) + .toStringAsFixed(0), + ); + } + + @override + void dispose() { + _lowController.dispose(); + _highController.dispose(); + _switchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final units = UnitFormatter(ref.watch(settingsProvider)); + final notifier = ref.read(divePlanNotifierProvider.notifier); + + Widget field( + TextEditingController controller, + String label, + void Function(double) onChanged, { + double Function(double)? toMetric, + }) { + return Expanded( + child: TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + isDense: true, + border: const OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (text) { + final parsed = double.tryParse(text); + if (parsed == null || parsed <= 0) return; + onChanged(toMetric != null ? toMetric(parsed) : parsed); + }, + ), + ); + } + + // Display-units depth back to meters. + double depthToMetric(double display) { + final factor = units.convertDepth(1.0); + return factor > 0 ? display / factor : display; + } + + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + field( + _lowController, + context.l10n.plannerCanvas_ccr_setpointLow, + (v) => notifier.updateSetpoints(low: v), + ), + const SizedBox(width: 8), + field( + _highController, + context.l10n.plannerCanvas_ccr_setpointHigh, + (v) => notifier.updateSetpoints(high: v), + ), + const SizedBox(width: 8), + field( + _switchController, + '${context.l10n.plannerCanvas_ccr_switchDepth} ' + '(${units.depthSymbol})', + (v) => notifier.updateSetpoints(switchDepth: v), + toMetric: depthToMetric, + ), + ], + ), + ); + } +} diff --git a/lib/features/planner/presentation/widgets/plan_canvas_chart.dart b/lib/features/planner/presentation/widgets/plan_canvas_chart.dart index cd5a45a84..608d5e75f 100644 --- a/lib/features/planner/presentation/widgets/plan_canvas_chart.dart +++ b/lib/features/planner/presentation/widgets/plan_canvas_chart.dart @@ -311,7 +311,7 @@ class _EmptyState extends ConsumerWidget { } } -class _ScrubReadout extends StatelessWidget { +class _ScrubReadout extends ConsumerWidget { const _ScrubReadout({ required this.runtimeSeconds, required this.depthMeters, @@ -323,9 +323,22 @@ class _ScrubReadout extends StatelessWidget { final UnitFormatter units; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final minutes = (runtimeSeconds / 60).round(); + // Scrub-to-bailout: on a CCR plan with bailout gas, show the bailout TTS + // from the nearest sampled instant. + final bailout = ref.watch(planBailoutProvider); + var text = context.l10n.plannerCanvas_scrub_readout( + minutes.toString(), + units.formatDepth(depthMeters, decimals: 0), + ); + if (bailout != null) { + final point = bailout.nearest(runtimeSeconds); + text += + ' · ' + '${context.l10n.plannerCanvas_scrub_bailout('${(point.ttsSeconds / 60).ceil()}')}'; + } return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( @@ -338,10 +351,7 @@ class _ScrubReadout extends StatelessWidget { ), ), child: Text( - context.l10n.plannerCanvas_scrub_readout( - minutes.toString(), - units.formatDepth(depthMeters, decimals: 0), - ), + text, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w600, ), diff --git a/lib/features/planner/presentation/widgets/plan_results_sheet.dart b/lib/features/planner/presentation/widgets/plan_results_sheet.dart index dfaabde9b..fa3c02fd0 100644 --- a/lib/features/planner/presentation/widgets/plan_results_sheet.dart +++ b/lib/features/planner/presentation/widgets/plan_results_sheet.dart @@ -5,6 +5,7 @@ import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.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/domain/services/bailout_solver.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'; @@ -101,6 +102,8 @@ class PlanResultsSheet extends ConsumerWidget { return tank.name ?? tank.gasMix.name; } + final bailout = ref.watch(planBailoutProvider); + return ListView( controller: controller, padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), @@ -112,6 +115,11 @@ class PlanResultsSheet extends ConsumerWidget { _SectionHeader(context.l10n.divePlanner_label_gasConsumption), for (final usage in outcome.tankUsages) _GasRow(usage: usage, label: tankLabel(usage.tankId), units: units), + if (bailout != null) ...[ + const SizedBox(height: 20), + _SectionHeader(context.l10n.plannerCanvas_bailout_title), + _BailoutSection(outcome: bailout, units: units), + ], const SizedBox(height: 20), _SectionHeader(context.l10n.divePlanner_label_warnings), if (outcome.issues.isEmpty) @@ -319,3 +327,77 @@ class _IssueRow extends StatelessWidget { ); } } + +class _BailoutSection extends StatelessWidget { + const _BailoutSection({required this.outcome, required this.units}); + + final BailoutOutcome outcome; + final UnitFormatter units; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final worst = outcome.worstCase; + final shortfallColor = outcome.sufficient + ? theme.colorScheme.onSurface + : theme.colorScheme.error; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.plannerCanvas_bailout_worstCase( + '${(worst.runtimeSeconds / 60).round()}', + units.formatDepth(worst.depthMeters, decimals: 0), + ), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + context.l10n.plannerCanvas_bailout_tts( + '${(worst.ttsSeconds / 60).ceil()}', + ), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + context.l10n.plannerCanvas_bailout_required( + worst.litersRequired.toStringAsFixed(0), + ), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: shortfallColor, + ), + ), + const SizedBox(width: 16), + Text( + context.l10n.plannerCanvas_bailout_available( + outcome.availableLiters.toStringAsFixed(0), + ), + style: theme.textTheme.bodyMedium, + ), + ], + ), + if (!outcome.sufficient) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(Icons.error, size: 18, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.plannerCanvas_bailout_insufficient, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index ec918cbd7..9e1f8f78f 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3235,6 +3235,15 @@ "onboarding_welcome_nameValidation": "الرجاء إدخال اسمك", "onboarding_welcome_subtitle": "تسجيل وتحليل متقدم للغوص", "onboarding_welcome_title": "مرحباً بك في Submersion", + "plannerCanvas_bailout_available": "المتاح {liters} لتر", + "plannerCanvas_bailout_insufficient": "غاز الإنقاذ غير كافٍ لأسوأ حالة", + "plannerCanvas_bailout_required": "المطلوب {liters} لتر", + "plannerCanvas_bailout_title": "إنقاذ (دائرة مفتوحة)", + "plannerCanvas_bailout_tts": "TTS للإنقاذ {minutes}′", + "plannerCanvas_bailout_worstCase": "أسوأ حالة عند {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "نقطة الضبط العليا (bar)", + "plannerCanvas_ccr_setpointLow": "نقطة الضبط الدنيا (bar)", + "plannerCanvas_ccr_switchDepth": "عمق تبديل نقطة الضبط", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{مشكلة واحدة} other{{count} مشكلات}}", "plannerCanvas_issue_gasDensityCritical": "كثافة الغاز {value} g/L تتجاوز الحد الأقصى", @@ -3248,6 +3257,7 @@ "plannerCanvas_saved_duplicate": "تكرار", "plannerCanvas_saved_empty": "لا توجد خطط محفوظة بعد", "plannerCanvas_saved_title": "الخطط المحفوظة", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "العمق", "plannerCanvas_table_gas": "الغاز", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index e30cb13a3..98f0f702f 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3235,6 +3235,15 @@ "onboarding_welcome_nameValidation": "Bitte geben Sie Ihren Namen ein", "onboarding_welcome_subtitle": "Erweiterte Tauchprotokollierung und -analyse", "onboarding_welcome_title": "Willkommen bei Submersion", + "plannerCanvas_bailout_available": "Verfügbar {liters} L", + "plannerCanvas_bailout_insufficient": "Bailout-Gas reicht für den schlimmsten Fall nicht aus", + "plannerCanvas_bailout_required": "Benötigt {liters} L", + "plannerCanvas_bailout_title": "Bailout (offener Kreislauf)", + "plannerCanvas_bailout_tts": "Bailout-TTS {minutes}′", + "plannerCanvas_bailout_worstCase": "Schlimmster Fall bei {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "Setpoint hoch (bar)", + "plannerCanvas_ccr_setpointLow": "Setpoint niedrig (bar)", + "plannerCanvas_ccr_switchDepth": "Setpoint-Wechseltiefe", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 Problem} other{{count} Probleme}}", "plannerCanvas_issue_gasDensityCritical": "Gasdichte {value} g/L über hartem Limit", @@ -3248,6 +3257,7 @@ "plannerCanvas_saved_duplicate": "Duplizieren", "plannerCanvas_saved_empty": "Noch keine gespeicherten Pläne", "plannerCanvas_saved_title": "Gespeicherte Pläne", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "Tiefe", "plannerCanvas_table_gas": "Gas", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 89ddc309f..502f2d534 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5635,6 +5635,36 @@ } } }, + "plannerCanvas_bailout_available": "Available {liters} L", + "@plannerCanvas_bailout_available": { + "placeholders": { + "liters": { "type": "String" } + } + }, + "plannerCanvas_bailout_insufficient": "Bailout gas insufficient for the worst case", + "plannerCanvas_bailout_required": "Required {liters} L", + "@plannerCanvas_bailout_required": { + "placeholders": { + "liters": { "type": "String" } + } + }, + "plannerCanvas_bailout_title": "Bailout (open circuit)", + "plannerCanvas_bailout_tts": "Bailout TTS {minutes}′", + "@plannerCanvas_bailout_tts": { + "placeholders": { + "minutes": { "type": "String" } + } + }, + "plannerCanvas_bailout_worstCase": "Worst case at {minutes}′ · {depth}", + "@plannerCanvas_bailout_worstCase": { + "placeholders": { + "minutes": { "type": "String" }, + "depth": { "type": "String" } + } + }, + "plannerCanvas_ccr_setpointHigh": "Setpoint high (bar)", + "plannerCanvas_ccr_setpointLow": "Setpoint low (bar)", + "plannerCanvas_ccr_switchDepth": "Setpoint switch depth", "plannerCanvas_chip_cns": "CNS {value}%", "@plannerCanvas_chip_cns": { "placeholders": { @@ -5679,6 +5709,12 @@ "plannerCanvas_saved_duplicate": "Duplicate", "plannerCanvas_saved_empty": "No saved plans yet", "plannerCanvas_saved_title": "Saved plans", + "plannerCanvas_scrub_bailout": "BO {minutes}′", + "@plannerCanvas_scrub_bailout": { + "placeholders": { + "minutes": { "type": "String" } + } + }, "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "@plannerCanvas_scrub_readout": { "placeholders": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 73240e171..9d31bc2f9 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3235,6 +3235,15 @@ "onboarding_welcome_nameValidation": "Por favor ingresa tu nombre", "onboarding_welcome_subtitle": "Registro y análisis avanzado de inmersiones", "onboarding_welcome_title": "Bienvenido a Submersion", + "plannerCanvas_bailout_available": "Disponible {liters} L", + "plannerCanvas_bailout_insufficient": "Gas de bailout insuficiente para el peor caso", + "plannerCanvas_bailout_required": "Necesario {liters} L", + "plannerCanvas_bailout_title": "Bailout (circuito abierto)", + "plannerCanvas_bailout_tts": "TTS de bailout {minutes}′", + "plannerCanvas_bailout_worstCase": "Peor caso a los {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "Setpoint alto (bar)", + "plannerCanvas_ccr_setpointLow": "Setpoint bajo (bar)", + "plannerCanvas_ccr_switchDepth": "Profundidad de cambio de setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problema} other{{count} problemas}}", "plannerCanvas_issue_gasDensityCritical": "Densidad del gas {value} g/L sobre el límite máximo", @@ -3248,6 +3257,7 @@ "plannerCanvas_saved_duplicate": "Duplicar", "plannerCanvas_saved_empty": "Aún no hay planes guardados", "plannerCanvas_saved_title": "Planes guardados", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "Profundidad", "plannerCanvas_table_gas": "Gas", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 93664500c..5a6b8f923 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3201,6 +3201,15 @@ "onboarding_welcome_nameValidation": "Veuillez entrer votre nom", "onboarding_welcome_subtitle": "Enregistrement et analyse avancés de plongée", "onboarding_welcome_title": "Bienvenue dans Submersion", + "plannerCanvas_bailout_available": "Disponible {liters} L", + "plannerCanvas_bailout_insufficient": "Gaz de bailout insuffisant pour le pire cas", + "plannerCanvas_bailout_required": "Nécessaire {liters} L", + "plannerCanvas_bailout_title": "Bailout (circuit ouvert)", + "plannerCanvas_bailout_tts": "TTS bailout {minutes}′", + "plannerCanvas_bailout_worstCase": "Pire cas à {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "Setpoint haut (bar)", + "plannerCanvas_ccr_setpointLow": "Setpoint bas (bar)", + "plannerCanvas_ccr_switchDepth": "Profondeur de bascule du setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problème} other{{count} problèmes}}", "plannerCanvas_issue_gasDensityCritical": "Densité du gaz {value} g/L au-dessus de la limite stricte", @@ -3214,6 +3223,7 @@ "plannerCanvas_saved_duplicate": "Dupliquer", "plannerCanvas_saved_empty": "Aucun plan enregistré", "plannerCanvas_saved_title": "Plans enregistrés", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "Profondeur", "plannerCanvas_table_gas": "Gaz", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 080d004fa..95e836a55 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3201,6 +3201,15 @@ "onboarding_welcome_nameValidation": "נא להזין את שמך", "onboarding_welcome_subtitle": "רישום וניתוח צלילה מתקדם", "onboarding_welcome_title": "ברוכים הבאים ל-Submersion", + "plannerCanvas_bailout_available": "זמין {liters} ליטר", + "plannerCanvas_bailout_insufficient": "גז חילוץ אינו מספיק למקרה הגרוע ביותר", + "plannerCanvas_bailout_required": "נדרש {liters} ליטר", + "plannerCanvas_bailout_title": "חילוץ (מעגל פתוח)", + "plannerCanvas_bailout_tts": "TTS חילוץ {minutes}′", + "plannerCanvas_bailout_worstCase": "המקרה הגרוע ב-{minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "נקודת כיוון גבוהה (bar)", + "plannerCanvas_ccr_setpointLow": "נקודת כיוון נמוכה (bar)", + "plannerCanvas_ccr_switchDepth": "עומק החלפת נקודת הכיוון", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{בעיה אחת} other{{count} בעיות}}", "plannerCanvas_issue_gasDensityCritical": "צפיפות הגז {value} g/L מעל הגבול המרבי", @@ -3214,6 +3223,7 @@ "plannerCanvas_saved_duplicate": "שכפול", "plannerCanvas_saved_empty": "אין עדיין תוכניות שמורות", "plannerCanvas_saved_title": "תוכניות שמורות", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "עומק", "plannerCanvas_table_gas": "גז", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 250ab81f1..66a303374 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3201,6 +3201,15 @@ "onboarding_welcome_nameValidation": "Add meg a neved", "onboarding_welcome_subtitle": "Fejlett merülés napló és elemzés", "onboarding_welcome_title": "Üdvözöl a Submersion", + "plannerCanvas_bailout_available": "Elérhető {liters} L", + "plannerCanvas_bailout_insufficient": "A bailout gáz nem elegendő a legrosszabb esethez", + "plannerCanvas_bailout_required": "Szükséges {liters} L", + "plannerCanvas_bailout_title": "Bailout (nyitott rendszer)", + "plannerCanvas_bailout_tts": "Bailout TTS {minutes}′", + "plannerCanvas_bailout_worstCase": "Legrosszabb eset {minutes}′-nél · {depth}", + "plannerCanvas_ccr_setpointHigh": "Magas setpoint (bar)", + "plannerCanvas_ccr_setpointLow": "Alacsony setpoint (bar)", + "plannerCanvas_ccr_switchDepth": "Setpoint váltási mélység", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 probléma} other{{count} probléma}}", "plannerCanvas_issue_gasDensityCritical": "Gázsűrűség {value} g/L a szigorú határ felett", @@ -3214,6 +3223,7 @@ "plannerCanvas_saved_duplicate": "Duplikálás", "plannerCanvas_saved_empty": "Még nincsenek mentett tervek", "plannerCanvas_saved_title": "Mentett tervek", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "Mélység", "plannerCanvas_table_gas": "Gáz", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 98e394a55..3cee2b5a4 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3201,6 +3201,15 @@ "onboarding_welcome_nameValidation": "Inserisci il tuo nome", "onboarding_welcome_subtitle": "Registrazione e analisi avanzata delle immersioni", "onboarding_welcome_title": "Benvenuto in Submersion", + "plannerCanvas_bailout_available": "Disponibile {liters} L", + "plannerCanvas_bailout_insufficient": "Gas di bailout insufficiente per il caso peggiore", + "plannerCanvas_bailout_required": "Necessario {liters} L", + "plannerCanvas_bailout_title": "Bailout (circuito aperto)", + "plannerCanvas_bailout_tts": "TTS bailout {minutes}′", + "plannerCanvas_bailout_worstCase": "Caso peggiore a {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "Setpoint alto (bar)", + "plannerCanvas_ccr_setpointLow": "Setpoint basso (bar)", + "plannerCanvas_ccr_switchDepth": "Profondità di cambio setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problema} other{{count} problemi}}", "plannerCanvas_issue_gasDensityCritical": "Densità del gas {value} g/L oltre il limite massimo", @@ -3214,6 +3223,7 @@ "plannerCanvas_saved_duplicate": "Duplica", "plannerCanvas_saved_empty": "Nessun piano salvato", "plannerCanvas_saved_title": "Piani salvati", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "Profondità", "plannerCanvas_table_gas": "Gas", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index d7d63fef5..8309fd1ab 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17654,6 +17654,60 @@ abstract class AppLocalizations { /// **'Welcome to Submersion'** String get onboarding_welcome_title; + /// No description provided for @plannerCanvas_bailout_available. + /// + /// In en, this message translates to: + /// **'Available {liters} L'** + String plannerCanvas_bailout_available(String liters); + + /// No description provided for @plannerCanvas_bailout_insufficient. + /// + /// In en, this message translates to: + /// **'Bailout gas insufficient for the worst case'** + String get plannerCanvas_bailout_insufficient; + + /// No description provided for @plannerCanvas_bailout_required. + /// + /// In en, this message translates to: + /// **'Required {liters} L'** + String plannerCanvas_bailout_required(String liters); + + /// No description provided for @plannerCanvas_bailout_title. + /// + /// In en, this message translates to: + /// **'Bailout (open circuit)'** + String get plannerCanvas_bailout_title; + + /// No description provided for @plannerCanvas_bailout_tts. + /// + /// In en, this message translates to: + /// **'Bailout TTS {minutes}′'** + String plannerCanvas_bailout_tts(String minutes); + + /// No description provided for @plannerCanvas_bailout_worstCase. + /// + /// In en, this message translates to: + /// **'Worst case at {minutes}′ · {depth}'** + String plannerCanvas_bailout_worstCase(String minutes, String depth); + + /// No description provided for @plannerCanvas_ccr_setpointHigh. + /// + /// In en, this message translates to: + /// **'Setpoint high (bar)'** + String get plannerCanvas_ccr_setpointHigh; + + /// No description provided for @plannerCanvas_ccr_setpointLow. + /// + /// In en, this message translates to: + /// **'Setpoint low (bar)'** + String get plannerCanvas_ccr_setpointLow; + + /// No description provided for @plannerCanvas_ccr_switchDepth. + /// + /// In en, this message translates to: + /// **'Setpoint switch depth'** + String get plannerCanvas_ccr_switchDepth; + /// No description provided for @plannerCanvas_chip_cns. /// /// In en, this message translates to: @@ -17732,6 +17786,12 @@ abstract class AppLocalizations { /// **'Saved plans'** String get plannerCanvas_saved_title; + /// No description provided for @plannerCanvas_scrub_bailout. + /// + /// In en, this message translates to: + /// **'BO {minutes}′'** + String plannerCanvas_scrub_bailout(String minutes); + /// No description provided for @plannerCanvas_scrub_readout. /// /// 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 2a1904613..b12ee0957 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10163,6 +10163,42 @@ class AppLocalizationsAr extends AppLocalizations { @override String get onboarding_welcome_title => 'مرحباً بك في Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'المتاح $liters لتر'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'غاز الإنقاذ غير كافٍ لأسوأ حالة'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'المطلوب $liters لتر'; + } + + @override + String get plannerCanvas_bailout_title => 'إنقاذ (دائرة مفتوحة)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'TTS للإنقاذ $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'أسوأ حالة عند $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'نقطة الضبط العليا (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'نقطة الضبط الدنيا (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => 'عمق تبديل نقطة الضبط'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10222,6 +10258,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get plannerCanvas_saved_title => 'الخطط المحفوظة'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 2db0c30f7..0074c66eb 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10357,6 +10357,42 @@ class AppLocalizationsDe extends AppLocalizations { @override String get onboarding_welcome_title => 'Willkommen bei Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Verfügbar $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'Bailout-Gas reicht für den schlimmsten Fall nicht aus'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Benötigt $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (offener Kreislauf)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'Bailout-TTS $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Schlimmster Fall bei $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Setpoint hoch (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Setpoint niedrig (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => 'Setpoint-Wechseltiefe'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10416,6 +10452,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Gespeicherte Pläne'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 28f083b11..6f57f40a4 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10196,6 +10196,42 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboarding_welcome_title => 'Welcome to Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Available $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'Bailout gas insufficient for the worst case'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Required $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (open circuit)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'Bailout TTS $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Worst case at $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Setpoint high (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Setpoint low (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => 'Setpoint switch depth'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10255,6 +10291,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Saved plans'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index c9a0d2f63..5efeff911 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10345,6 +10345,43 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboarding_welcome_title => 'Bienvenido a Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Disponible $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'Gas de bailout insuficiente para el peor caso'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Necesario $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (circuito abierto)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'TTS de bailout $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Peor caso a los $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Setpoint alto (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Setpoint bajo (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => + 'Profundidad de cambio de setpoint'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10404,6 +10441,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Planes guardados'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index b1f700c84..e71aa9cc7 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10395,6 +10395,43 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboarding_welcome_title => 'Bienvenue dans Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Disponible $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'Gaz de bailout insuffisant pour le pire cas'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Nécessaire $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (circuit ouvert)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'TTS bailout $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Pire cas à $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Setpoint haut (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Setpoint bas (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => + 'Profondeur de bascule du setpoint'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10454,6 +10491,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Plans enregistrés'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 7423a99fc..9652ac7ad 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10096,6 +10096,42 @@ class AppLocalizationsHe extends AppLocalizations { @override String get onboarding_welcome_title => 'ברוכים הבאים ל-Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'זמין $liters ליטר'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'גז חילוץ אינו מספיק למקרה הגרוע ביותר'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'נדרש $liters ליטר'; + } + + @override + String get plannerCanvas_bailout_title => 'חילוץ (מעגל פתוח)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'TTS חילוץ $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'המקרה הגרוע ב-$minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'נקודת כיוון גבוהה (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'נקודת כיוון נמוכה (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => 'עומק החלפת נקודת הכיוון'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10155,6 +10191,11 @@ class AppLocalizationsHe extends AppLocalizations { @override String get plannerCanvas_saved_title => 'תוכניות שמורות'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 332649d43..f63e71f66 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10333,6 +10333,42 @@ class AppLocalizationsHu extends AppLocalizations { @override String get onboarding_welcome_title => 'Üdvözöl a Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Elérhető $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'A bailout gáz nem elegendő a legrosszabb esethez'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Szükséges $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (nyitott rendszer)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'Bailout TTS $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Legrosszabb eset $minutes′-nél · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Magas setpoint (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Alacsony setpoint (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => 'Setpoint váltási mélység'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10392,6 +10428,11 @@ class AppLocalizationsHu extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Mentett tervek'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 1fdbfcff7..9ceb7f4dd 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10361,6 +10361,42 @@ class AppLocalizationsIt extends AppLocalizations { @override String get onboarding_welcome_title => 'Benvenuto in Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Disponibile $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'Gas di bailout insufficiente per il caso peggiore'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Necessario $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (circuito aperto)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'TTS bailout $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Caso peggiore a $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Setpoint alto (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Setpoint basso (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => 'Profondità di cambio setpoint'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10421,6 +10457,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Piani salvati'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 27d06d363..dcfd0b97a 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10284,6 +10284,42 @@ class AppLocalizationsNl extends AppLocalizations { @override String get onboarding_welcome_title => 'Welkom bij Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Beschikbaar $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'Bailoutgas onvoldoende voor het slechtste geval'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Nodig $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (open circuit)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'Bailout-TTS $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Slechtste geval op $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Setpoint hoog (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Setpoint laag (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => 'Setpoint-wisseldiepte'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10343,6 +10379,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Opgeslagen plannen'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 4f1dcabbd..ea29abe7a 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10366,6 +10366,43 @@ class AppLocalizationsPt extends AppLocalizations { @override String get onboarding_welcome_title => 'Bem-vindo ao Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return 'Disponível $liters L'; + } + + @override + String get plannerCanvas_bailout_insufficient => + 'Gás de bailout insuficiente para o pior caso'; + + @override + String plannerCanvas_bailout_required(String liters) { + return 'Necessário $liters L'; + } + + @override + String get plannerCanvas_bailout_title => 'Bailout (circuito aberto)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return 'TTS de bailout $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return 'Pior caso aos $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => 'Setpoint alto (bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => 'Setpoint baixo (bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => + 'Profundidade de troca do setpoint'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -10425,6 +10462,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get plannerCanvas_saved_title => 'Planos salvos'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 095bbf577..397d7bb0a 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9912,6 +9912,41 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboarding_welcome_title => '欢迎使用 Submersion'; + @override + String plannerCanvas_bailout_available(String liters) { + return '可用 $liters 升'; + } + + @override + String get plannerCanvas_bailout_insufficient => '逃生气体不足以应对最坏情况'; + + @override + String plannerCanvas_bailout_required(String liters) { + return '需要 $liters 升'; + } + + @override + String get plannerCanvas_bailout_title => '逃生(开式呼吸)'; + + @override + String plannerCanvas_bailout_tts(String minutes) { + return '逃生 TTS $minutes′'; + } + + @override + String plannerCanvas_bailout_worstCase(String minutes, String depth) { + return '最坏情况在 $minutes′ · $depth'; + } + + @override + String get plannerCanvas_ccr_setpointHigh => '高设定点(bar)'; + + @override + String get plannerCanvas_ccr_setpointLow => '低设定点(bar)'; + + @override + String get plannerCanvas_ccr_switchDepth => '设定点切换深度'; + @override String plannerCanvas_chip_cns(String value) { return 'CNS $value%'; @@ -9968,6 +10003,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get plannerCanvas_saved_title => '已保存的计划'; + @override + String plannerCanvas_scrub_bailout(String minutes) { + return 'BO $minutes′'; + } + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 4f64eec43..16bc13f8e 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3235,6 +3235,15 @@ "onboarding_welcome_nameValidation": "Voer je naam in", "onboarding_welcome_subtitle": "Geavanceerd duiklogboek en analyse", "onboarding_welcome_title": "Welkom bij Submersion", + "plannerCanvas_bailout_available": "Beschikbaar {liters} L", + "plannerCanvas_bailout_insufficient": "Bailoutgas onvoldoende voor het slechtste geval", + "plannerCanvas_bailout_required": "Nodig {liters} L", + "plannerCanvas_bailout_title": "Bailout (open circuit)", + "plannerCanvas_bailout_tts": "Bailout-TTS {minutes}′", + "plannerCanvas_bailout_worstCase": "Slechtste geval op {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "Setpoint hoog (bar)", + "plannerCanvas_ccr_setpointLow": "Setpoint laag (bar)", + "plannerCanvas_ccr_switchDepth": "Setpoint-wisseldiepte", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 probleem} other{{count} problemen}}", "plannerCanvas_issue_gasDensityCritical": "Gasdichtheid {value} g/L boven harde limiet", @@ -3248,6 +3257,7 @@ "plannerCanvas_saved_duplicate": "Dupliceren", "plannerCanvas_saved_empty": "Nog geen opgeslagen plannen", "plannerCanvas_saved_title": "Opgeslagen plannen", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "Diepte", "plannerCanvas_table_gas": "Gas", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 0d9f14b29..69622652a 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3235,6 +3235,15 @@ "onboarding_welcome_nameValidation": "Digite seu nome", "onboarding_welcome_subtitle": "Registro e análise avançada de mergulhos", "onboarding_welcome_title": "Bem-vindo ao Submersion", + "plannerCanvas_bailout_available": "Disponível {liters} L", + "plannerCanvas_bailout_insufficient": "Gás de bailout insuficiente para o pior caso", + "plannerCanvas_bailout_required": "Necessário {liters} L", + "plannerCanvas_bailout_title": "Bailout (circuito aberto)", + "plannerCanvas_bailout_tts": "TTS de bailout {minutes}′", + "plannerCanvas_bailout_worstCase": "Pior caso aos {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "Setpoint alto (bar)", + "plannerCanvas_ccr_setpointLow": "Setpoint baixo (bar)", + "plannerCanvas_ccr_switchDepth": "Profundidade de troca do setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problema} other{{count} problemas}}", "plannerCanvas_issue_gasDensityCritical": "Densidade do gás {value} g/L acima do limite máximo", @@ -3248,6 +3257,7 @@ "plannerCanvas_saved_duplicate": "Duplicar", "plannerCanvas_saved_empty": "Ainda não há planos salvos", "plannerCanvas_saved_title": "Planos salvos", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "Profundidade", "plannerCanvas_table_gas": "Gás", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 8ae02693e..a38abdbd1 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3385,6 +3385,15 @@ "onboarding_welcome_nameValidation": "请输入您的名字", "onboarding_welcome_subtitle": "高级潜水日志与分析", "onboarding_welcome_title": "欢迎使用 Submersion", + "plannerCanvas_bailout_available": "可用 {liters} 升", + "plannerCanvas_bailout_insufficient": "逃生气体不足以应对最坏情况", + "plannerCanvas_bailout_required": "需要 {liters} 升", + "plannerCanvas_bailout_title": "逃生(开式呼吸)", + "plannerCanvas_bailout_tts": "逃生 TTS {minutes}′", + "plannerCanvas_bailout_worstCase": "最坏情况在 {minutes}′ · {depth}", + "plannerCanvas_ccr_setpointHigh": "高设定点(bar)", + "plannerCanvas_ccr_setpointLow": "低设定点(bar)", + "plannerCanvas_ccr_switchDepth": "设定点切换深度", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, other{{count} 个问题}}", "plannerCanvas_issue_gasDensityCritical": "气体密度 {value} g/L 超过硬性上限", @@ -3398,6 +3407,7 @@ "plannerCanvas_saved_duplicate": "复制", "plannerCanvas_saved_empty": "尚无已保存的计划", "plannerCanvas_saved_title": "已保存的计划", + "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "plannerCanvas_table_depth": "深度", "plannerCanvas_table_gas": "气体", diff --git a/test/features/planner/ccr_ui_test.dart b/test/features/planner/ccr_ui_test.dart new file mode 100644 index 000000000..945aa5677 --- /dev/null +++ b/test/features/planner/ccr_ui_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.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/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/presentation/pages/plan_canvas_page.dart'; +import 'package:submersion/features/planner/presentation/widgets/plan_results_sheet.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../helpers/test_app.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() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + List overrides() => [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ]; + + testWidgets('badge toggles between OC and CCR', (tester) async { + tester.view.physicalSize = const Size(420, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + testApp(overrides: overrides(), child: const PlanCanvasPage()), + ); + await tester.pumpAndSettle(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanCanvasPage)), + ); + expect(find.text('OC'), findsOneWidget); + + await tester.tap(find.text('OC')); + await tester.pumpAndSettle(); + + expect(container.read(divePlanNotifierProvider).mode, domain.PlanMode.ccr); + expect(find.text('CCR'), findsOneWidget); + }); + + testWidgets('CCR plan with bailout tank shows the bailout section', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: overrides(), + child: SizedBox( + width: 500, + height: 700, + child: PlanResultsSheet(controller: ScrollController()), + ), + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanResultsSheet)), + ); + final notifier = container.read(divePlanNotifierProvider.notifier); + notifier.addSimplePlan(maxDepth: 45, bottomTimeMinutes: 25); + notifier.updateMode(domain.PlanMode.ccr); + notifier.addTank( + const DiveTank( + id: 'bo', + volume: 11.1, + startPressure: 207, + gasMix: GasMix(o2: 50), + role: TankRole.bailout, + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Bailout'), findsWidgets); + expect(find.textContaining('Worst case'), findsOneWidget); + expect(find.textContaining('Required'), findsOneWidget); + }); +} From 986f635cce4b31e9c1ae97327f64d253af687112 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 16:46:52 -0400 Subject: [PATCH 7/7] test(planner): raise phase 4 CCR patch coverage; address review comments - CCR switch depth accepts 0 m (surface); setpoints still require a positive value (allowZero on that field only) - Bailout required/available volumes now use UnitFormatter (respect L vs cuft); drop the hardcoded L from the bailout strings across locales - Cover the CCR settings section and the bailout volume formatting --- .../widgets/ccr_settings_section.dart | 8 +++- .../widgets/plan_results_sheet.dart | 4 +- lib/l10n/arb/app_ar.arb | 4 +- lib/l10n/arb/app_de.arb | 4 +- lib/l10n/arb/app_en.arb | 4 +- lib/l10n/arb/app_es.arb | 4 +- lib/l10n/arb/app_fr.arb | 4 +- lib/l10n/arb/app_he.arb | 4 +- lib/l10n/arb/app_hu.arb | 4 +- lib/l10n/arb/app_it.arb | 4 +- lib/l10n/arb/app_localizations.dart | 4 +- lib/l10n/arb/app_localizations_ar.dart | 4 +- lib/l10n/arb/app_localizations_de.dart | 4 +- lib/l10n/arb/app_localizations_en.dart | 4 +- lib/l10n/arb/app_localizations_es.dart | 4 +- lib/l10n/arb/app_localizations_fr.dart | 4 +- lib/l10n/arb/app_localizations_he.dart | 4 +- lib/l10n/arb/app_localizations_hu.dart | 4 +- lib/l10n/arb/app_localizations_it.dart | 4 +- lib/l10n/arb/app_localizations_nl.dart | 4 +- lib/l10n/arb/app_localizations_pt.dart | 4 +- lib/l10n/arb/app_localizations_zh.dart | 4 +- lib/l10n/arb/app_nl.arb | 4 +- lib/l10n/arb/app_pt.arb | 4 +- lib/l10n/arb/app_zh.arb | 4 +- test/features/planner/ccr_ui_test.dart | 40 +++++++++++++++++++ 26 files changed, 95 insertions(+), 49 deletions(-) diff --git a/lib/features/planner/presentation/widgets/ccr_settings_section.dart b/lib/features/planner/presentation/widgets/ccr_settings_section.dart index 0a5808f90..840ffe5d5 100644 --- a/lib/features/planner/presentation/widgets/ccr_settings_section.dart +++ b/lib/features/planner/presentation/widgets/ccr_settings_section.dart @@ -56,6 +56,7 @@ class _CcrSettingsSectionState extends ConsumerState { String label, void Function(double) onChanged, { double Function(double)? toMetric, + bool allowZero = false, }) { return Expanded( child: TextFormField( @@ -68,7 +69,11 @@ class _CcrSettingsSectionState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (text) { final parsed = double.tryParse(text); - if (parsed == null || parsed <= 0) return; + // Setpoints must be positive; a switch depth of 0 (surface) is a + // valid, useful configuration, so it opts into allowZero. + if (parsed == null || (allowZero ? parsed < 0 : parsed <= 0)) { + return; + } onChanged(toMetric != null ? toMetric(parsed) : parsed); }, ), @@ -103,6 +108,7 @@ class _CcrSettingsSectionState extends ConsumerState { '(${units.depthSymbol})', (v) => notifier.updateSetpoints(switchDepth: v), toMetric: depthToMetric, + allowZero: true, ), ], ), diff --git a/lib/features/planner/presentation/widgets/plan_results_sheet.dart b/lib/features/planner/presentation/widgets/plan_results_sheet.dart index fa3c02fd0..2adc98948 100644 --- a/lib/features/planner/presentation/widgets/plan_results_sheet.dart +++ b/lib/features/planner/presentation/widgets/plan_results_sheet.dart @@ -363,7 +363,7 @@ class _BailoutSection extends StatelessWidget { children: [ Text( context.l10n.plannerCanvas_bailout_required( - worst.litersRequired.toStringAsFixed(0), + units.formatVolume(worst.litersRequired), ), style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, @@ -373,7 +373,7 @@ class _BailoutSection extends StatelessWidget { const SizedBox(width: 16), Text( context.l10n.plannerCanvas_bailout_available( - outcome.availableLiters.toStringAsFixed(0), + units.formatVolume(outcome.availableLiters), ), style: theme.textTheme.bodyMedium, ), diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 9e1f8f78f..1c6d0218f 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3235,9 +3235,9 @@ "onboarding_welcome_nameValidation": "الرجاء إدخال اسمك", "onboarding_welcome_subtitle": "تسجيل وتحليل متقدم للغوص", "onboarding_welcome_title": "مرحباً بك في Submersion", - "plannerCanvas_bailout_available": "المتاح {liters} لتر", + "plannerCanvas_bailout_available": "المتاح {liters}", "plannerCanvas_bailout_insufficient": "غاز الإنقاذ غير كافٍ لأسوأ حالة", - "plannerCanvas_bailout_required": "المطلوب {liters} لتر", + "plannerCanvas_bailout_required": "المطلوب {liters}", "plannerCanvas_bailout_title": "إنقاذ (دائرة مفتوحة)", "plannerCanvas_bailout_tts": "TTS للإنقاذ {minutes}′", "plannerCanvas_bailout_worstCase": "أسوأ حالة عند {minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 98f0f702f..6a00e8ee7 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3235,9 +3235,9 @@ "onboarding_welcome_nameValidation": "Bitte geben Sie Ihren Namen ein", "onboarding_welcome_subtitle": "Erweiterte Tauchprotokollierung und -analyse", "onboarding_welcome_title": "Willkommen bei Submersion", - "plannerCanvas_bailout_available": "Verfügbar {liters} L", + "plannerCanvas_bailout_available": "Verfügbar {liters}", "plannerCanvas_bailout_insufficient": "Bailout-Gas reicht für den schlimmsten Fall nicht aus", - "plannerCanvas_bailout_required": "Benötigt {liters} L", + "plannerCanvas_bailout_required": "Benötigt {liters}", "plannerCanvas_bailout_title": "Bailout (offener Kreislauf)", "plannerCanvas_bailout_tts": "Bailout-TTS {minutes}′", "plannerCanvas_bailout_worstCase": "Schlimmster Fall bei {minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 502f2d534..5627a2607 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5635,14 +5635,14 @@ } } }, - "plannerCanvas_bailout_available": "Available {liters} L", + "plannerCanvas_bailout_available": "Available {liters}", "@plannerCanvas_bailout_available": { "placeholders": { "liters": { "type": "String" } } }, "plannerCanvas_bailout_insufficient": "Bailout gas insufficient for the worst case", - "plannerCanvas_bailout_required": "Required {liters} L", + "plannerCanvas_bailout_required": "Required {liters}", "@plannerCanvas_bailout_required": { "placeholders": { "liters": { "type": "String" } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 9d31bc2f9..7e8dd68db 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3235,9 +3235,9 @@ "onboarding_welcome_nameValidation": "Por favor ingresa tu nombre", "onboarding_welcome_subtitle": "Registro y análisis avanzado de inmersiones", "onboarding_welcome_title": "Bienvenido a Submersion", - "plannerCanvas_bailout_available": "Disponible {liters} L", + "plannerCanvas_bailout_available": "Disponible {liters}", "plannerCanvas_bailout_insufficient": "Gas de bailout insuficiente para el peor caso", - "plannerCanvas_bailout_required": "Necesario {liters} L", + "plannerCanvas_bailout_required": "Necesario {liters}", "plannerCanvas_bailout_title": "Bailout (circuito abierto)", "plannerCanvas_bailout_tts": "TTS de bailout {minutes}′", "plannerCanvas_bailout_worstCase": "Peor caso a los {minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 5a6b8f923..7c1d01bc9 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3201,9 +3201,9 @@ "onboarding_welcome_nameValidation": "Veuillez entrer votre nom", "onboarding_welcome_subtitle": "Enregistrement et analyse avancés de plongée", "onboarding_welcome_title": "Bienvenue dans Submersion", - "plannerCanvas_bailout_available": "Disponible {liters} L", + "plannerCanvas_bailout_available": "Disponible {liters}", "plannerCanvas_bailout_insufficient": "Gaz de bailout insuffisant pour le pire cas", - "plannerCanvas_bailout_required": "Nécessaire {liters} L", + "plannerCanvas_bailout_required": "Nécessaire {liters}", "plannerCanvas_bailout_title": "Bailout (circuit ouvert)", "plannerCanvas_bailout_tts": "TTS bailout {minutes}′", "plannerCanvas_bailout_worstCase": "Pire cas à {minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 95e836a55..aea0e4475 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3201,9 +3201,9 @@ "onboarding_welcome_nameValidation": "נא להזין את שמך", "onboarding_welcome_subtitle": "רישום וניתוח צלילה מתקדם", "onboarding_welcome_title": "ברוכים הבאים ל-Submersion", - "plannerCanvas_bailout_available": "זמין {liters} ליטר", + "plannerCanvas_bailout_available": "זמין {liters}", "plannerCanvas_bailout_insufficient": "גז חילוץ אינו מספיק למקרה הגרוע ביותר", - "plannerCanvas_bailout_required": "נדרש {liters} ליטר", + "plannerCanvas_bailout_required": "נדרש {liters}", "plannerCanvas_bailout_title": "חילוץ (מעגל פתוח)", "plannerCanvas_bailout_tts": "TTS חילוץ {minutes}′", "plannerCanvas_bailout_worstCase": "המקרה הגרוע ב-{minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 66a303374..a6800ee4e 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3201,9 +3201,9 @@ "onboarding_welcome_nameValidation": "Add meg a neved", "onboarding_welcome_subtitle": "Fejlett merülés napló és elemzés", "onboarding_welcome_title": "Üdvözöl a Submersion", - "plannerCanvas_bailout_available": "Elérhető {liters} L", + "plannerCanvas_bailout_available": "Elérhető {liters}", "plannerCanvas_bailout_insufficient": "A bailout gáz nem elegendő a legrosszabb esethez", - "plannerCanvas_bailout_required": "Szükséges {liters} L", + "plannerCanvas_bailout_required": "Szükséges {liters}", "plannerCanvas_bailout_title": "Bailout (nyitott rendszer)", "plannerCanvas_bailout_tts": "Bailout TTS {minutes}′", "plannerCanvas_bailout_worstCase": "Legrosszabb eset {minutes}′-nél · {depth}", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 3cee2b5a4..6352b814c 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3201,9 +3201,9 @@ "onboarding_welcome_nameValidation": "Inserisci il tuo nome", "onboarding_welcome_subtitle": "Registrazione e analisi avanzata delle immersioni", "onboarding_welcome_title": "Benvenuto in Submersion", - "plannerCanvas_bailout_available": "Disponibile {liters} L", + "plannerCanvas_bailout_available": "Disponibile {liters}", "plannerCanvas_bailout_insufficient": "Gas di bailout insufficiente per il caso peggiore", - "plannerCanvas_bailout_required": "Necessario {liters} L", + "plannerCanvas_bailout_required": "Necessario {liters}", "plannerCanvas_bailout_title": "Bailout (circuito aperto)", "plannerCanvas_bailout_tts": "TTS bailout {minutes}′", "plannerCanvas_bailout_worstCase": "Caso peggiore a {minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 8309fd1ab..28ad8736e 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17657,7 +17657,7 @@ abstract class AppLocalizations { /// No description provided for @plannerCanvas_bailout_available. /// /// In en, this message translates to: - /// **'Available {liters} L'** + /// **'Available {liters}'** String plannerCanvas_bailout_available(String liters); /// No description provided for @plannerCanvas_bailout_insufficient. @@ -17669,7 +17669,7 @@ abstract class AppLocalizations { /// No description provided for @plannerCanvas_bailout_required. /// /// In en, this message translates to: - /// **'Required {liters} L'** + /// **'Required {liters}'** String plannerCanvas_bailout_required(String liters); /// No description provided for @plannerCanvas_bailout_title. diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index b12ee0957..50c47f6fb 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10165,7 +10165,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'المتاح $liters لتر'; + return 'المتاح $liters'; } @override @@ -10174,7 +10174,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'المطلوب $liters لتر'; + return 'المطلوب $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 0074c66eb..ba205d2a1 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10359,7 +10359,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Verfügbar $liters L'; + return 'Verfügbar $liters'; } @override @@ -10368,7 +10368,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Benötigt $liters L'; + return 'Benötigt $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 6f57f40a4..c8b586836 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10198,7 +10198,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Available $liters L'; + return 'Available $liters'; } @override @@ -10207,7 +10207,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Required $liters L'; + return 'Required $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 5efeff911..73764e139 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10347,7 +10347,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Disponible $liters L'; + return 'Disponible $liters'; } @override @@ -10356,7 +10356,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Necesario $liters L'; + return 'Necesario $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e71aa9cc7..3ef5e6fce 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10397,7 +10397,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Disponible $liters L'; + return 'Disponible $liters'; } @override @@ -10406,7 +10406,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Nécessaire $liters L'; + return 'Nécessaire $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 9652ac7ad..55c8f53c1 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10098,7 +10098,7 @@ class AppLocalizationsHe extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'זמין $liters ליטר'; + return 'זמין $liters'; } @override @@ -10107,7 +10107,7 @@ class AppLocalizationsHe extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'נדרש $liters ליטר'; + return 'נדרש $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index f63e71f66..117c1e8b4 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10335,7 +10335,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Elérhető $liters L'; + return 'Elérhető $liters'; } @override @@ -10344,7 +10344,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Szükséges $liters L'; + return 'Szükséges $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 9ceb7f4dd..4f7fa239f 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10363,7 +10363,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Disponibile $liters L'; + return 'Disponibile $liters'; } @override @@ -10372,7 +10372,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Necessario $liters L'; + return 'Necessario $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index dcfd0b97a..c1c8a698f 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10286,7 +10286,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Beschikbaar $liters L'; + return 'Beschikbaar $liters'; } @override @@ -10295,7 +10295,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Nodig $liters L'; + return 'Nodig $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index ea29abe7a..50bb22aa3 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10368,7 +10368,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return 'Disponível $liters L'; + return 'Disponível $liters'; } @override @@ -10377,7 +10377,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return 'Necessário $liters L'; + return 'Necessário $liters'; } @override diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 397d7bb0a..aea61d247 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9914,7 +9914,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String plannerCanvas_bailout_available(String liters) { - return '可用 $liters 升'; + return '可用 $liters'; } @override @@ -9922,7 +9922,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String plannerCanvas_bailout_required(String liters) { - return '需要 $liters 升'; + return '需要 $liters'; } @override diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 16bc13f8e..508ef4b55 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3235,9 +3235,9 @@ "onboarding_welcome_nameValidation": "Voer je naam in", "onboarding_welcome_subtitle": "Geavanceerd duiklogboek en analyse", "onboarding_welcome_title": "Welkom bij Submersion", - "plannerCanvas_bailout_available": "Beschikbaar {liters} L", + "plannerCanvas_bailout_available": "Beschikbaar {liters}", "plannerCanvas_bailout_insufficient": "Bailoutgas onvoldoende voor het slechtste geval", - "plannerCanvas_bailout_required": "Nodig {liters} L", + "plannerCanvas_bailout_required": "Nodig {liters}", "plannerCanvas_bailout_title": "Bailout (open circuit)", "plannerCanvas_bailout_tts": "Bailout-TTS {minutes}′", "plannerCanvas_bailout_worstCase": "Slechtste geval op {minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 69622652a..17187ebad 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3235,9 +3235,9 @@ "onboarding_welcome_nameValidation": "Digite seu nome", "onboarding_welcome_subtitle": "Registro e análise avançada de mergulhos", "onboarding_welcome_title": "Bem-vindo ao Submersion", - "plannerCanvas_bailout_available": "Disponível {liters} L", + "plannerCanvas_bailout_available": "Disponível {liters}", "plannerCanvas_bailout_insufficient": "Gás de bailout insuficiente para o pior caso", - "plannerCanvas_bailout_required": "Necessário {liters} L", + "plannerCanvas_bailout_required": "Necessário {liters}", "plannerCanvas_bailout_title": "Bailout (circuito aberto)", "plannerCanvas_bailout_tts": "TTS de bailout {minutes}′", "plannerCanvas_bailout_worstCase": "Pior caso aos {minutes}′ · {depth}", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index a38abdbd1..a0ef15158 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3385,9 +3385,9 @@ "onboarding_welcome_nameValidation": "请输入您的名字", "onboarding_welcome_subtitle": "高级潜水日志与分析", "onboarding_welcome_title": "欢迎使用 Submersion", - "plannerCanvas_bailout_available": "可用 {liters} 升", + "plannerCanvas_bailout_available": "可用 {liters}", "plannerCanvas_bailout_insufficient": "逃生气体不足以应对最坏情况", - "plannerCanvas_bailout_required": "需要 {liters} 升", + "plannerCanvas_bailout_required": "需要 {liters}", "plannerCanvas_bailout_title": "逃生(开式呼吸)", "plannerCanvas_bailout_tts": "逃生 TTS {minutes}′", "plannerCanvas_bailout_worstCase": "最坏情况在 {minutes}′ · {depth}", diff --git a/test/features/planner/ccr_ui_test.dart b/test/features/planner/ccr_ui_test.dart index 945aa5677..d50a38df3 100644 --- a/test/features/planner/ccr_ui_test.dart +++ b/test/features/planner/ccr_ui_test.dart @@ -9,6 +9,7 @@ import 'package:submersion/features/dive_planner/presentation/providers/dive_pla import 'package:submersion/features/planner/domain/entities/dive_plan.dart' as domain; import 'package:submersion/features/planner/presentation/pages/plan_canvas_page.dart'; +import 'package:submersion/features/planner/presentation/widgets/ccr_settings_section.dart'; import 'package:submersion/features/planner/presentation/widgets/plan_results_sheet.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -96,5 +97,44 @@ void main() { expect(find.textContaining('Bailout'), findsWidgets); expect(find.textContaining('Worst case'), findsOneWidget); expect(find.textContaining('Required'), findsOneWidget); + // Required/available volumes respect the diver's volume unit (default L). + expect(find.textContaining(RegExp(r'Required \d+ L')), findsOneWidget); + }); + + testWidgets('CCR settings edit all setpoints; switch depth accepts 0', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: overrides(), + child: const SizedBox(width: 500, child: CcrSettingsSection()), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(CcrSettingsSection)), + ); + final fields = find.byType(TextFormField); + expect(fields, findsNWidgets(3)); + + // Distinct from the pre-filled defaults (0.7 / 1.3 / 10) so onChanged fires. + await tester.enterText(fields.at(0), '0.8'); + await tester.enterText(fields.at(1), '1.5'); + await tester.enterText(fields.at(2), '15'); + await tester.pumpAndSettle(); + final state = container.read(divePlanNotifierProvider); + expect(state.setpointLow, 0.8); + expect(state.setpointHigh, 1.5); + expect(state.setpointSwitchDepth, 15.0); + + // Switch depth 0 (high setpoint from the surface) is a valid config. + await tester.enterText(fields.at(2), '0'); + await tester.pumpAndSettle(); + expect(container.read(divePlanNotifierProvider).setpointSwitchDepth, 0.0); + + // A zero setpoint is still rejected, leaving the prior value untouched. + await tester.enterText(fields.at(0), '0'); + await tester.pumpAndSettle(); + expect(container.read(divePlanNotifierProvider).setpointLow, 0.8); }); }