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