From b68a2a9891d8167cd56cf5c71af5ea79e3de70d5 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 11:44:59 -0400 Subject: [PATCH 1/8] docs: add Phase 3 Live Profile Canvas implementation plan Claude-Session: https://claude.ai/code/session_01CMosDc8A1zcntatqFZba1k --- .../2026-07-05-dive-planner-phase3-canvas.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-dive-planner-phase3-canvas.md diff --git a/docs/superpowers/plans/2026-07-05-dive-planner-phase3-canvas.md b/docs/superpowers/plans/2026-07-05-dive-planner-phase3-canvas.md new file mode 100644 index 000000000..4fc2b2278 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dive-planner-phase3-canvas.md @@ -0,0 +1,154 @@ +# Dive Planner Phase 3: Live Profile Canvas UI — 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:** Replace the 3-tab planner page with the approved Live Profile Canvas (chart-centric, live recalc, results in a swipe-up sheet, adaptive to wide screens), driven by Phase 2's `PlanEngine`, plus a saved-plans surface — then cut the route over. + +**Architecture:** New UI lives in `lib/features/planner/presentation/` (the Phase 2 module). Editing state stays on the existing `divePlanNotifierProvider` (all editing panels — `PlanSettingsPanel`, `PlanTankList`, `SegmentList` — are self-contained ConsumerWidgets and embed as-is). All DISPLAYED numbers come from a new `planOutcomeProvider` (PlanEngine), not the legacy `planResultsProvider`. Branch: stacked on `worktree-dive-planner-phase2-domain` (PR #485). + +**Tech Stack:** fl_chart (inverted-Y convention from `plan_profile_chart.dart`), `ResponsiveBreakpoints`, `DraggableScrollableSheet`, `testApp` widget-test helper, `flutter gen-l10n` with 11 ARB locales. + +**Spec:** `docs/superpowers/specs/2026-07-05-dive-planner-redesign-design.md` ("Planner UI — Live Profile Canvas (Phase 3)"). Mockup approved 2026-07-05 (Live Profile Canvas, option A). + +## Global Constraints + +- Every user-facing string is a NEW l10n key `plannerCanvas__` added to `lib/l10n/arb/app_en.arb` AND hand-translated into all 10 non-en locales (ar, de, es, fr, he, hu, it, nl, pt, zh), keys alphabetical, then `flutter gen-l10n`. No hardcoded UI strings. +- All displayed values respect unit settings via `UnitFormatter(settings)` (depth symbols, pressure). +- Deferred per spec: direct waypoint drag-editing, tissue bars in the sheet, contingency ghost overlays (Phase 5), CCR toggle (Phase 4). The chart's ceiling overlay is the segment-end/stop approximation (exact per-sample envelope arrives with profile-grade timelines later). +- Old editing widgets are REUSED (settings/tanks/segments/segment-editor dialogs); old page + results/chart widgets are deleted at cutover (`dive_planner_page.dart`, `deco_results_panel.dart`, `gas_results_panel.dart`, `plan_profile_chart.dart`) along with `plannerTabIndexProvider`. +- `dart format .`, whole-project `flutter analyze`, specific-file test runs; commit per task; no Co-Authored-By. + +--- + +### Task 1: Canvas data layer — `planOutcomeProvider` + `PlanCanvasSeries` + +**Files:** +- Create: `lib/features/planner/presentation/providers/plan_canvas_providers.dart` +- Test: `test/features/planner/plan_canvas_providers_test.dart` + +**Interfaces (later tasks consume exactly these):** + +```dart +final planEngineConfigProvider = Provider; // from settings +final planOutcomeProvider = Provider; // PlanEngine on current state +final scrubTimeProvider = StateProvider((_) => null); // seconds, null = no cursor +final planResultsSheetSectionProvider = StateProvider((_) => 0); + +class CanvasPoint { final double timeSeconds; final double depth; } +class CanvasMarker { final double timeSeconds; final double depth; final String label; } +class PlanCanvasSeries { + final List profile; // segments + computed ascent tail + final List ceiling; // approximate envelope, may be empty + final List gasSwitches; // where the ascent gas changes + final List stopLabels; // "9m·6'" style, one per stop + final double maxTimeSeconds; final double maxDepth; +} +final planCanvasSeriesProvider = Provider; +``` + +**Implementation:** +- `planEngineConfigProvider`: `PlanEngineConfig(ppO2Working: ref.watch(ppO2MaxWorkingProvider), ppO2Deco: ref.watch(ppO2MaxDecoProvider), cnsWarningThreshold: ref.watch(cnsWarningThresholdProvider), o2Narcotic: )`. +- `planOutcomeProvider`: `PlanEngine(config: ref.watch(planEngineConfigProvider)).compute(divePlanFromState(ref.watch(divePlanNotifierProvider)))`. +- `planCanvasSeriesProvider` builds from state segments (sorted by order: points at each segment start/end using cumulative runtime) then appends the ascent tail from `outcome.stops`: for each stop, a point at `(arrivalRuntimeSeconds, depthMeters)` and `(arrival + durationSeconds, depthMeters)`; final point at `(outcome.runtimeSeconds, 0)`. Ceiling: `(endRuntime, ceilingAtEnd)` per SegmentOutcome (skip zeros before the first nonzero) + `(arrival, depthMeters)` per stop. Gas switches: first stop whose gas differs from the last segment's gas + each stop whose gas differs from the previous stop; label via `GasMix(o2: fO2*100, he: fHe*100).name`. Stop labels: every stop, `"${depth}m·${ceil(min)}'"` composed in the WIDGET with l10n/units (series stores raw numbers: marker label may stay data-only here — put gas NAME in the marker since it is unit-free, and let stopLabels carry the raw values via label built in widget; simplest: series stores gas-name markers and stop markers with empty label + widget formats). + +- [ ] Write failing unit tests: simple 30 m/25 min air plan → profile starts at (0,0), is monotonic in time, last point is (runtimeSeconds, 0); deco plan (45 m/25 min) → profile contains a flat sub-sequence per stop and stop markers count == stops count; trimix + EAN50/O2 plan → gasSwitches non-empty and located at ≤ 22 m depths; scrub provider defaults null. Use a `ProviderContainer` with `settingsProvider.overrideWith(_TestSettingsNotifier)` (copy the stub from `save_load_round_trip_test.dart`). +- [ ] Implement; run `flutter test test/features/planner/plan_canvas_providers_test.dart` → PASS. +- [ ] `dart format .` + commit `feat(planner): canvas data providers on PlanEngine`. + +--- + +### Task 2: The canvas chart — `PlanCanvasChart` + +**Files:** +- Create: `lib/features/planner/presentation/widgets/plan_canvas_chart.dart` +- Test: `test/features/planner/plan_canvas_chart_test.dart` + +**Spec:** ConsumerWidget watching `planCanvasSeriesProvider`, `scrubTimeProvider`, `selectedSegmentIdProvider`, `settingsProvider` (units). Follow `plan_profile_chart.dart`'s conventions exactly (negative-Y depth, `_calculateInterval`, axis titles, bounds `minX:0/maxX*1.05/minY:-maxDepth*1.1/maxY:0`) with these additions: + +1. **Profile line**: single `LineChartBarData` (primary color, gradient belowBar) built from `series.profile` (x = seconds/60). +2. **Ceiling overlay**: second `LineChartBarData` from `series.ceiling`, `dashArray: [4, 4]`, `colorScheme.error` at 70% opacity, no fill, hidden when empty. +3. **Gas-switch markers**: `ExtraLinesData(verticalLines:)` — one `VerticalLine` per `series.gasSwitches` with `VerticalLineLabel(show: true, labelResolver: (_) => marker.label)` (pattern: `dive_profile_chart.dart:4288-4330`). +4. **Scrub cursor**: `lineTouchData.touchCallback` — on drag/tap events store `response.lineBarSpots?.first.x * 60` into `scrubTimeProvider` and clear it on `FlPanEndEvent`/`FlTapUpEvent`/`FlPointerExitEvent` (pattern: `dive_profile_chart.dart:2251-2292`); when scrubTime != null add a grey dashed `VerticalLine` at it; suppress default tooltips (`handleBuiltInTouches: false` + custom `getTouchedSpotIndicator` returning subtle indicators). +5. **Scrub readout**: a small overlay chip (top-left inside the chart's Stack) shown when scrubbing: `RT {min}' · {depth}{unit}` — depth interpolated from `series.profile` at the scrub time. Style like `_StatChip` (`plan_profile_chart.dart:306`). +6. **Segment selection**: on `FlTapUpEvent`, map the tapped time to the covering user segment (cumulative durations from state.segments) and set `selectedSegmentIdProvider`; taps beyond the last segment clear it. +7. Empty state: reuse the empty-Card idiom with `plannerCanvas_empty_title` / `_subtitle` keys and a Quick Plan button setting `showSimplePlanDialogProvider`. + +- [ ] Widget tests (via `testApp`): with a seeded notifier (use `divePlanNotifierProvider.notifier` in the container to `addSimplePlan(maxDepth: 45, bottomTimeMinutes: 25)` inside `overrides`-free pump then `pumpAndSettle`): `LineChart` renders; empty plan shows the empty-state title. (Interaction-level scrub tests are impractical in fl_chart unit tests — cover the time→segment mapping and profile interpolation as pure functions exported from the widget file and unit-test those directly.) +- [ ] Implement; run the test file → PASS; format + commit `feat(planner): live canvas chart with ceiling, switches, and scrub`. + +--- + +### Task 3: Status chips + results sheet + +**Files:** +- Create: `lib/features/planner/presentation/widgets/plan_status_chips.dart` +- Create: `lib/features/planner/presentation/widgets/plan_results_sheet.dart` +- Test: `test/features/planner/plan_results_widgets_test.dart` + +**`PlanStatusChips`** (ConsumerWidget, horizontal `Wrap`): RT (runtimeSeconds, highlighted), TTS (ttsAtBottom or NDL when not in deco: show `NDL {min}'` if ndlAtBottom >= 0 else `TTS {min}'`), Deco (`totalDecoSeconds`, hidden when 0), CNS (`cnsEnd`%, warn-tinted ≥ warning threshold), Issues chip (`{n} issues`, tinted by the max severity color, hidden when 0; `onTap` callback provided by the page to open the sheet). Chip visual = `_StatChip` pill pattern. + +**`PlanResultsSheet`** — the CONTENT column (the page owns the `DraggableScrollableSheet`); takes a `ScrollController`. Sections, each a labeled header: +1. **Runtime table**: header row (Depth / Stop / Runtime / Gas) + one row per `PlanStop`: depth (units), `ceil(duration/60)'` (+ small `+{airBreak}'` suffix when airBreakSeconds > 0), `arrival+duration` runtime minutes, gas name (`GasMix.name`). Empty → `plannerCanvas_results_noDeco` row. +2. **Gas**: per `PlanTankUsage` row: tank name (lookup in state.tanks) + gas name, liters used (rounded), remaining pressure (units; `error` color when reserveViolation, plain otherwise), percent bar (`LinearProgressIndicator`). +3. **Issues**: severity-styled rows modeled on `_WarningRow` (`deco_results_panel.dart:292-338`): critical → `Icons.error`/`colorScheme.error`; alert → `Icons.warning`/orange; warning → `Icons.warning_amber`/orange; info → `Icons.info_outline`. Message text: `PlanIssue.message` is engine-composed English — for l10n, map `PlanIssueType` → localized template (same approach as `_formatWarningMessage`, `deco_results_panel.dart:340`) using `issue.value`/`issue.threshold`; fall back to `issue.message` for safety. + +- [ ] Widget tests: seeded deco plan → chips show a TTS value and an issues count; sheet shows ≥1 runtime row, a gas row with remaining pressure text, and (for a 45 m air plan) a critical gas-density issue row rendered with the error icon. +- [ ] Implement; test file PASS; format + commit `feat(planner): status chips and results sheet on PlanOutcome`. + +--- + +### Task 4: Saved-plans sheet + app-bar actions + +**Files:** +- Create: `lib/features/planner/presentation/widgets/saved_plans_sheet.dart` +- Test: `test/features/planner/saved_plans_sheet_test.dart` + +**`SavedPlansSheet`** (shown via `showModalBottomSheet`): watches `divePlanSummariesProvider`. Each tile: name, relative updated date, trailing summary chips (max depth in units, runtime min, TTS min when present), `onTap` → `context.pop()` then `context.go('/planning/dive-planner/${summary.id}')`; trailing `PopupMenuButton` with Duplicate (repository.duplicatePlan) and Delete (confirm `AlertDialog`, then repository.deletePlan). Empty state text. Loading via `AsyncValue.when` BUT render stale data during reload (`asyncValue.value` pattern per project memory — avoid the spinner flash). + +- [ ] Widget tests (FK-ON test DB via `setUpTestDatabase` + `testApp`): with two saved plans (insert via `DivePlanRepository`), the sheet lists both names newest-first; delete flow removes one after confirm (verify repository count). +- [ ] Implement; PASS; format + commit `feat(planner): saved plans sheet`. + +--- + +### Task 5: Page assembly + adaptive layout + route cutover + +**Files:** +- Create: `lib/features/planner/presentation/pages/plan_canvas_page.dart` +- Modify: `lib/core/router/app_router.dart` (routes `divePlanner` + `editPlan` → `PlanCanvasPage`) +- Delete: `lib/features/dive_planner/presentation/pages/dive_planner_page.dart`, `.../widgets/deco_results_panel.dart`, `.../widgets/gas_results_panel.dart`, `.../widgets/plan_profile_chart.dart`; remove `plannerTabIndexProvider` from `dive_planner_providers.dart`. +- Test: `test/features/planner/plan_canvas_page_test.dart` + +**`PlanCanvasPage`** (`ConsumerStatefulWidget`, `{String? planId}`): +- `initState`: the same `Future.microtask(loadPlanById)` block as the old page (copy from `dive_planner_page.dart:43-64` before deleting it). +- **AppBar**: title = plan name (tap → rename dialog with a `TextField`, `updateName`); an `OC` badge chip; actions: save icon (ports `_savePlan` from the old page but summary numbers from `planOutcomeProvider`: maxDepth/runtimeSeconds/ttsAtBottom), overflow `PopupMenuButton` {Quick plan → `SimplePlanDialog`, Saved plans → `SavedPlansSheet`, New plan → confirm-if-dirty then `newPlan()`, Plan settings → modal sheet embedding `PlanSettingsPanel`, Convert to dive → port `_convertToDive` verbatim from the old page}. +- **Phone body** (`ResponsiveBreakpoints.isMobile`): `Stack[ Column[ SizedBox(h: 42% of height, child: PlanCanvasChart), PlanStatusChips, Expanded(ListView[SegmentList(), PlanTankList()]) ], DraggableScrollableSheet(minChildSize: .08, initialChildSize: .08, maxChildSize: .85, snap: true, builder: (ctx, ctrl) => Material(elevation, rounded top) > PlanResultsSheet(controller: ctrl)) ]`. The Issues chip's onTap animates the sheet controller (keep a `DraggableScrollableController`). +- **Wide body** (≥ `ResponsiveBreakpoints.desktop`): `Row[ SizedBox(360, ListView[PlanSettingsPanel(), PlanTankList(), SegmentList()]), VerticalDivider, Expanded(Column[Expanded(PlanCanvasChart), PlanStatusChips, SizedBox(h:240, PlanResultsSheet(controller: ScrollController()))]) ]` — the sheet content doubles as the always-visible results pane; no Stack/sheet. +- **Cutover**: router lines 205-217 swap `DivePlannerPage` → `PlanCanvasPage` (import swap); delete the four old files; fix any remaining imports (`grep -rn "dive_planner_page\|DecoResultsPanel\|GasResultsPanel\|PlanProfileChart" lib/ test/`); delete tests that exclusively covered deleted widgets (check `test/features/dive_planner/`), keep everything else green. + +- [ ] Widget tests: page pumps with a simple plan → shows chart + chips + segment list (phone size via `tester.view.physicalSize`); wide size (1400×900 logical) shows `PlanSettingsPanel` and NO `DraggableScrollableSheet`; save action invokes notifier.save (verify `isDirty` false after tap with test DB). +- [ ] Run `flutter test test/features/planner/ test/features/dive_planner/` — all green (old page tests removed/adjusted). +- [ ] Format + commit `feat(planner): Live Profile Canvas page and route cutover`. + +--- + +### Task 6: l10n — keys in en + 10 locales + +**Files:** all 11 `lib/l10n/arb/app_*.arb`. + +- [ ] Collect every `plannerCanvas_*` key used by Tasks 2-5 (grep `l10n.plannerCanvas_` across `lib/features/planner/`). Expected ≈ 25-35 keys: empty state, chip labels (RT/TTS/NDL/Deco/CNS/issues), sheet section headers (Runtime table/Gas/Issues), runtime table column headers, no-deco row, saved-plans sheet (title, empty, duplicate, delete, delete-confirm title/body), rename dialog, menu items (quick plan/saved plans/new plan/plan settings/convert), unsaved-changes confirm, issue-type message templates (one per `PlanIssueType`, with `{value}`/`{threshold}`/`{depth}` placeholders where needed — add `@`-metadata ONLY for keys with placeholders). +- [ ] Add to `app_en.arb` alphabetically; translate into ar/de/es/fr/he/hu/it/nl/pt/zh following the chinese-localization plan rules (translate human text only, never placeholder names; alphabetical; no description-only metadata copies). +- [ ] `flutter gen-l10n` then `flutter analyze` (missing-key errors surface here) and re-run `flutter test test/features/planner/`. +- [ ] Format + commit `feat(planner): localize the canvas UI in all locales`. + +--- + +### Task 7: Verification sweep + +- [ ] `flutter analyze` clean; `dart format .` no changes. +- [ ] `flutter test test/features/planner/ test/features/dive_planner/` and `flutter test test/core/deco/` green; run any router/navigation test files that reference `/planning` (grep `test/` for `dive-planner`). +- [ ] Manual smoke note for the PR: run `flutter run -d macos`, open Planning → Dive Planner, quick-plan 45 m/25 min, observe live chart + chips + sheet; save; reopen via Saved plans. +- [ ] Commit anything outstanding. + +## Explicitly out of scope (later phases) + +Contingency ghost overlays and one-tap deviations (5); CCR toggle/badge switching and bailout scrub (4); tissue bars in the sheet and per-sample ceiling envelope; hub restyle + calculators (7); plan compare/slate/share (7); waypoint drag-editing (deferred indefinitely per spec). From cf99c518a1e1abe2e454080e1cc6aac257ef2669 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 11:58:28 -0400 Subject: [PATCH 2/8] feat(planner): canvas data providers on PlanEngine addSimplePlan now produces bottom-only segments so the PlanEngine computes the ascent and real deco stops (the legacy full-profile simple plan showed a safety stop but no deco). planCanvasSeriesProvider builds the profile, ceiling approximation, gas-switch markers, and stop labels; planOutcomeProvider runs the engine live off editing state. --- .../providers/dive_planner_providers.dart | 27 ++- .../providers/plan_canvas_providers.dart | 193 ++++++++++++++++++ .../planner/plan_canvas_providers_test.dart | 114 +++++++++++ 3 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 lib/features/planner/presentation/providers/plan_canvas_providers.dart create mode 100644 test/features/planner/plan_canvas_providers_test.dart diff --git a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart index 4274eda03..c9da4140e 100644 --- a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart +++ b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart @@ -193,7 +193,10 @@ class DivePlanNotifier extends StateNotifier { } } - /// Add a simple dive profile (descent + bottom + ascent). + /// Add a simple dive profile as the bottom portion only: a descent and a + /// bottom segment. The ascent and any decompression are always computed by + /// the PlanEngine from the deepest segment, so authoring a fixed ascent + /// here would suppress real deco stops. void addSimplePlan({ required double maxDepth, required int bottomTimeMinutes, @@ -201,11 +204,23 @@ class DivePlanNotifier extends StateNotifier { if (state.tanks.isEmpty) return; final tank = state.tanks.first; - final segments = _calculator.createSimplePlan( - maxDepth: maxDepth, - bottomTimeMinutes: bottomTimeMinutes, - tank: tank, - ); + final segments = [ + PlanSegment.descent( + id: _uuid.v4(), + targetDepth: maxDepth, + tankId: tank.id, + gasMix: tank.gasMix, + order: 0, + ), + PlanSegment.bottom( + id: _uuid.v4(), + depth: maxDepth, + durationMinutes: bottomTimeMinutes, + tankId: tank.id, + gasMix: tank.gasMix, + order: 1, + ), + ]; state = state.copyWith( segments: segments, diff --git a/lib/features/planner/presentation/providers/plan_canvas_providers.dart b/lib/features/planner/presentation/providers/plan_canvas_providers.dart new file mode 100644 index 000000000..2d057f1de --- /dev/null +++ b/lib/features/planner/presentation/providers/plan_canvas_providers.dart @@ -0,0 +1,193 @@ +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'; +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/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'; + +/// PlanEngine thresholds sourced from the diver's deco settings. +final planEngineConfigProvider = Provider((ref) { + return PlanEngineConfig( + ppO2Working: ref.watch(ppO2MaxWorkingProvider), + ppO2Deco: ref.watch(ppO2MaxDecoProvider), + cnsWarningThreshold: ref.watch(cnsWarningThresholdProvider), + o2Narcotic: ref.watch(settingsProvider).o2Narcotic, + ); +}); + +/// The canvas's single source of computed truth: the current editing state +/// run through the PlanEngine on every change (live recalc, no button). +final planOutcomeProvider = Provider((ref) { + final state = ref.watch(divePlanNotifierProvider); + final engine = PlanEngine(config: ref.watch(planEngineConfigProvider)); + return engine.compute(divePlanFromState(state)); +}); + +/// Scrub cursor position along the plan, in seconds (null = not scrubbing). +final scrubTimeProvider = StateProvider((_) => null); + +/// Which results-sheet section the last chip tap targeted (0 = top). +final planResultsSheetSectionProvider = StateProvider((_) => 0); + +/// One vertex of a canvas polyline (raw metric values; widgets convert). +class CanvasPoint { + final double timeSeconds; + final double depth; + + const CanvasPoint(this.timeSeconds, this.depth); +} + +/// A labeled marker on the canvas (gas switch, stop annotation). +class CanvasMarker { + final double timeSeconds; + final double depth; + final String label; + final int durationSeconds; + + const CanvasMarker( + this.timeSeconds, + this.depth, + this.label, { + this.durationSeconds = 0, + }); +} + +/// Everything the chart draws, precomputed from state + outcome. +class PlanCanvasSeries { + final List profile; + final List ceiling; + final List gasSwitches; + final List stopLabels; + final double maxTimeSeconds; + final double maxDepth; + + const PlanCanvasSeries({ + required this.profile, + required this.ceiling, + required this.gasSwitches, + required this.stopLabels, + required this.maxTimeSeconds, + required this.maxDepth, + }); + + bool get isEmpty => profile.length < 2; + + /// Depth on the profile at [timeSeconds], linearly interpolated. + double depthAt(double timeSeconds) { + if (profile.isEmpty) return 0; + if (timeSeconds <= profile.first.timeSeconds) return profile.first.depth; + for (var i = 1; i < profile.length; i++) { + final a = profile[i - 1]; + final b = profile[i]; + if (timeSeconds <= b.timeSeconds) { + final span = b.timeSeconds - a.timeSeconds; + if (span <= 0) return b.depth; + final t = (timeSeconds - a.timeSeconds) / span; + return a.depth + (b.depth - a.depth) * t; + } + } + return profile.last.depth; + } +} + +/// Chart series for the current plan: user segments plus the computed +/// ascent tail (travel legs + stops), ceiling approximation, and markers. +final planCanvasSeriesProvider = Provider((ref) { + final state = ref.watch(divePlanNotifierProvider); + final outcome = ref.watch(planOutcomeProvider); + + final segments = List.from(state.segments) + ..sort((a, b) => a.order.compareTo(b.order)); + + final profile = []; + var t = 0.0; + for (final segment in segments) { + if (profile.isEmpty || profile.last.depth != segment.startDepth) { + profile.add(CanvasPoint(t, segment.startDepth)); + } + t += segment.durationSeconds; + profile.add(CanvasPoint(t, segment.endDepth)); + } + + // Computed ascent tail: travel to each stop, hold, then surface. + final gasSwitches = []; + final stopLabels = []; + double? previousFO2; + double? previousFHe; + if (segments.isNotEmpty) { + previousFO2 = segments.last.gasMix.o2 / 100.0; + previousFHe = segments.last.gasMix.he / 100.0; + } + for (final stop in outcome.stops) { + profile.add( + CanvasPoint(stop.arrivalRuntimeSeconds.toDouble(), stop.depthMeters), + ); + profile.add( + CanvasPoint( + (stop.arrivalRuntimeSeconds + stop.durationSeconds).toDouble(), + stop.depthMeters, + ), + ); + stopLabels.add( + CanvasMarker( + stop.arrivalRuntimeSeconds.toDouble(), + stop.depthMeters, + '', + durationSeconds: stop.durationSeconds, + ), + ); + final switched = + previousFO2 == null || + (stop.gasFO2 - previousFO2).abs() > 0.005 || + (stop.gasFHe - (previousFHe ?? 0)).abs() > 0.005; + if (switched && previousFO2 != null) { + gasSwitches.add( + CanvasMarker( + stop.arrivalRuntimeSeconds.toDouble(), + stop.depthMeters, + GasMix(o2: stop.gasFO2 * 100.0, he: stop.gasFHe * 100.0).name, + ), + ); + } + previousFO2 = stop.gasFO2; + previousFHe = stop.gasFHe; + } + if (profile.isNotEmpty && profile.last.depth > 0) { + profile.add(CanvasPoint(outcome.runtimeSeconds.toDouble(), 0)); + } + + // Ceiling approximation: segment-end ceilings plus the stop staircase. + final ceiling = []; + for (final segmentOutcome in outcome.segmentOutcomes) { + if (segmentOutcome.ceilingAtEnd > 0) { + ceiling.add( + CanvasPoint( + segmentOutcome.endRuntime.toDouble(), + segmentOutcome.ceilingAtEnd, + ), + ); + } + } + for (final stop in outcome.stops) { + ceiling.add( + CanvasPoint(stop.arrivalRuntimeSeconds.toDouble(), stop.depthMeters), + ); + ceiling.add( + CanvasPoint( + (stop.arrivalRuntimeSeconds + stop.durationSeconds).toDouble(), + stop.depthMeters, + ), + ); + } + + return PlanCanvasSeries( + profile: profile, + ceiling: ceiling, + gasSwitches: gasSwitches, + stopLabels: stopLabels, + maxTimeSeconds: outcome.runtimeSeconds.toDouble(), + maxDepth: outcome.maxDepth, + ); +}); diff --git a/test/features/planner/plan_canvas_providers_test.dart b/test/features/planner/plan_canvas_providers_test.dart new file mode 100644 index 000000000..3ff5e4726 --- /dev/null +++ b/test/features/planner/plan_canvas_providers_test.dart @@ -0,0 +1,114 @@ +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/features/dive_log/domain/entities/dive.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/features/settings/presentation/providers/settings_providers.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); +} + +ProviderContainer _container() { + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + test('scrub provider defaults to null', () { + final container = _container(); + expect(container.read(scrubTimeProvider), isNull); + }); + + test('simple plan produces a surface-to-surface profile', () { + final container = _container(); + container + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 30.0, bottomTimeMinutes: 10); + final series = container.read(planCanvasSeriesProvider); + + expect(series.isEmpty, isFalse); + expect(series.profile.first.timeSeconds, 0); + expect(series.profile.first.depth, 0); + expect(series.profile.last.depth, 0); + // Time is monotonically non-decreasing. + for (var i = 1; i < series.profile.length; i++) { + expect( + series.profile[i].timeSeconds, + greaterThanOrEqualTo(series.profile[i - 1].timeSeconds), + ); + } + expect(series.maxDepth, 30.0); + expect(series.depthAt(0), 0); + }); + + test('deco plan appends stop flats and stop markers', () { + final container = _container(); + final notifier = container.read(divePlanNotifierProvider.notifier); + notifier.addSimplePlan(maxDepth: 45.0, bottomTimeMinutes: 25); + final outcome = container.read(planOutcomeProvider); + final series = container.read(planCanvasSeriesProvider); + + expect(outcome.stops, isNotEmpty); + expect(series.stopLabels, hasLength(outcome.stops.length)); + // Each stop contributes a flat: two consecutive points at equal depth. + for (final stop in outcome.stops) { + final flat = series.profile.where( + (p) => + p.depth == stop.depthMeters && + p.timeSeconds >= stop.arrivalRuntimeSeconds, + ); + expect( + flat.length, + greaterThanOrEqualTo(2), + reason: 'stop ${stop.depthMeters}', + ); + } + expect(series.ceiling, isNotEmpty); + }); + + test('multi-gas plan yields gas-switch markers at deco depths', () { + final container = _container(); + final notifier = container.read(divePlanNotifierProvider.notifier); + notifier.addSimplePlan(maxDepth: 45.0, bottomTimeMinutes: 25); + notifier.addTank( + const DiveTank( + id: 'ean50', + name: 'EAN50', + volume: 11.1, + startPressure: 207, + gasMix: GasMix(o2: 50), + role: TankRole.deco, + ), + ); + final series = container.read(planCanvasSeriesProvider); + + expect(series.gasSwitches, isNotEmpty); + for (final marker in series.gasSwitches) { + expect(marker.depth, lessThanOrEqualTo(22.0)); + expect(marker.label, isNotEmpty); + } + }); + + test('planEngineConfig reflects settings defaults', () { + final container = _container(); + final config = container.read(planEngineConfigProvider); + expect(config.ppO2Working, 1.4); + expect(config.ppO2Deco, 1.6); + }); +} From 31f1f96b9d7f93789b099cc9754e0fae04ede400 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 12:03:19 -0400 Subject: [PATCH 3/8] feat(planner): live canvas chart with ceiling, gas switches, and scrub --- .../widgets/plan_canvas_chart.dart | 351 ++++++++++++++++++ lib/l10n/arb/app_en.arb | 7 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 5 + lib/l10n/arb/app_localizations_de.dart | 5 + lib/l10n/arb/app_localizations_en.dart | 5 + lib/l10n/arb/app_localizations_es.dart | 5 + lib/l10n/arb/app_localizations_fr.dart | 5 + lib/l10n/arb/app_localizations_he.dart | 5 + lib/l10n/arb/app_localizations_hu.dart | 5 + lib/l10n/arb/app_localizations_it.dart | 5 + lib/l10n/arb/app_localizations_nl.dart | 5 + lib/l10n/arb/app_localizations_pt.dart | 5 + lib/l10n/arb/app_localizations_zh.dart | 5 + .../planner/plan_canvas_chart_test.dart | 92 +++++ 15 files changed, 511 insertions(+) create mode 100644 lib/features/planner/presentation/widgets/plan_canvas_chart.dart create mode 100644 test/features/planner/plan_canvas_chart_test.dart diff --git a/lib/features/planner/presentation/widgets/plan_canvas_chart.dart b/lib/features/planner/presentation/widgets/plan_canvas_chart.dart new file mode 100644 index 000000000..cd5a45a84 --- /dev/null +++ b/lib/features/planner/presentation/widgets/plan_canvas_chart.dart @@ -0,0 +1,351 @@ +import 'package:fl_chart/fl_chart.dart'; +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/domain/entities/plan_segment.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/dive_planner/presentation/widgets/simple_plan_dialog.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The user-authored segment whose time span covers [timeSeconds], or null +/// when the time falls in the computed ascent past the last segment. +/// +/// Pure and exported for unit testing (fl_chart interaction can't be driven +/// in widget tests). +String? segmentIdAtTime(List segments, double timeSeconds) { + final ordered = List.from(segments) + ..sort((a, b) => a.order.compareTo(b.order)); + var elapsed = 0.0; + for (final segment in ordered) { + final end = elapsed + segment.durationSeconds; + if (timeSeconds >= elapsed && timeSeconds <= end) return segment.id; + elapsed = end; + } + return null; +} + +/// The hero chart of the Live Profile Canvas: the planned profile with the +/// computed deco tail, a ceiling overlay, gas-switch markers, and a scrub +/// cursor with a live readout. +class PlanCanvasChart extends ConsumerWidget { + const PlanCanvasChart({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final series = ref.watch(planCanvasSeriesProvider); + final scrubTime = ref.watch(scrubTimeProvider); + final theme = Theme.of(context); + final settings = ref.watch(settingsProvider); + final units = UnitFormatter(settings); + + if (series.isEmpty) { + return _EmptyState(theme: theme); + } + + final maxDepthDisplay = units.convertDepth(series.maxDepth); + final maxTime = series.maxTimeSeconds / 60; + + return Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 12, 4), + child: LineChart( + _buildChartData( + context, + ref, + series, + scrubTime, + maxDepthDisplay, + maxTime, + theme, + units, + ), + ), + ), + ), + if (scrubTime != null) + Positioned( + top: 12, + left: 12, + child: _ScrubReadout( + runtimeSeconds: scrubTime, + depthMeters: series.depthAt(scrubTime), + units: units, + ), + ), + ], + ); + } + + LineChartData _buildChartData( + BuildContext context, + WidgetRef ref, + PlanCanvasSeries series, + double? scrubTime, + double maxDepthDisplay, + double maxTime, + ThemeData theme, + UnitFormatter units, + ) { + FlSpot toSpot(CanvasPoint p) => + FlSpot(p.timeSeconds / 60, -units.convertDepth(p.depth)); + + final profileSpots = series.profile.map(toSpot).toList(); + final ceilingSpots = series.ceiling.map(toSpot).toList(); + + final verticalLines = [ + for (final marker in series.gasSwitches) + VerticalLine( + x: marker.timeSeconds / 60, + color: theme.colorScheme.tertiary, + strokeWidth: 1.5, + dashArray: const [3, 3], + label: VerticalLineLabel( + show: true, + alignment: Alignment.topRight, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.tertiary, + ), + labelResolver: (_) => marker.label, + ), + ), + if (scrubTime != null) + VerticalLine( + x: scrubTime / 60, + color: theme.colorScheme.outline, + strokeWidth: 1, + dashArray: const [2, 3], + ), + ]; + + return LineChartData( + gridData: FlGridData( + show: true, + horizontalInterval: _calculateInterval(maxDepthDisplay), + verticalInterval: _calculateInterval(maxTime), + getDrawingHorizontalLine: (_) => FlLine( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + getDrawingVerticalLine: (_) => FlLine( + color: theme.colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + axisNameWidget: Text( + context.l10n.divePlanner_label_timeAxis, + style: theme.textTheme.labelSmall, + ), + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: _calculateInterval(maxTime), + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + value.toStringAsFixed(0), + style: theme.textTheme.labelSmall, + ), + ), + ), + ), + leftTitles: AxisTitles( + axisNameWidget: Text( + context.l10n.divePlanner_label_depthAxis(units.depthSymbol), + style: theme.textTheme.labelSmall, + ), + sideTitles: SideTitles( + showTitles: true, + reservedSize: 38, + interval: _calculateInterval(maxDepthDisplay), + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + (-value).toStringAsFixed(0), + style: theme.textTheme.labelSmall, + ), + ), + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + extraLinesData: ExtraLinesData(verticalLines: verticalLines), + minX: 0, + maxX: maxTime > 0 ? maxTime * 1.05 : 10, + minY: maxDepthDisplay > 0 ? -maxDepthDisplay * 1.1 : -10, + maxY: 0, + lineBarsData: [ + // Ceiling overlay (dashed, drawn under the profile line). + if (ceilingSpots.length >= 2) + LineChartBarData( + spots: ceilingSpots, + isCurved: false, + color: theme.colorScheme.error.withValues(alpha: 0.7), + barWidth: 1.5, + dashArray: const [4, 4], + dotData: const FlDotData(show: false), + ), + LineChartBarData( + spots: profileSpots, + isCurved: false, + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary, + theme.colorScheme.primary.withValues(alpha: 0.7), + ], + ), + barWidth: 3, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.05), + theme.colorScheme.primary.withValues(alpha: 0.3), + ], + ), + ), + ), + ], + lineTouchData: LineTouchData( + // Own scrub line + readout; suppress the built-in tooltip/indicator. + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) => spots.map((_) => null).toList(), + ), + getTouchedSpotIndicator: (barData, indexes) => indexes + .map( + (_) => const TouchedSpotIndicatorData( + FlLine(color: Colors.transparent), + FlDotData(show: false), + ), + ) + .toList(), + touchCallback: (event, response) { + final notifier = ref.read(scrubTimeProvider.notifier); + if (event is FlPanEndEvent || + event is FlTapUpEvent || + event is FlLongPressEnd || + event is FlPointerExitEvent) { + notifier.state = null; + return; + } + final spot = response?.lineBarSpots?.first; + if (spot != null) { + final seconds = spot.x * 60; + notifier.state = seconds; + if (event is FlTapUpEvent || event is FlTapDownEvent) { + final segments = ref.read(divePlanNotifierProvider).segments; + ref.read(selectedSegmentIdProvider.notifier).state = + segmentIdAtTime(segments, seconds); + } + } + }, + ), + ); + } + + double _calculateInterval(double maxValue) { + if (maxValue <= 0) return 5; + if (maxValue <= 10) return 2; + if (maxValue <= 20) return 5; + if (maxValue <= 50) return 10; + if (maxValue <= 100) return 20; + return 30; + } +} + +class _EmptyState extends ConsumerWidget { + const _EmptyState({required this.theme}); + + final ThemeData theme; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.show_chart, size: 48, color: theme.colorScheme.outline), + const SizedBox(height: 16), + Text( + context.l10n.divePlanner_message_noProfile, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + context.l10n.divePlanner_message_addSegmentsForProfile, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () => showDialog( + context: context, + builder: (_) => const SimplePlanDialog(), + ), + icon: const Icon(Icons.auto_awesome), + label: Text(context.l10n.divePlanner_action_quickPlan), + ), + ], + ), + ); + } +} + +class _ScrubReadout extends StatelessWidget { + const _ScrubReadout({ + required this.runtimeSeconds, + required this.depthMeters, + required this.units, + }); + + final double runtimeSeconds; + final double depthMeters; + final UnitFormatter units; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final minutes = (runtimeSeconds / 60).round(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.95, + ), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Text( + context.l10n.plannerCanvas_scrub_readout( + minutes.toString(), + units.formatDepth(depthMeters, decimals: 0), + ), + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 461511428..931ddbdc4 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5635,6 +5635,13 @@ } } }, + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "@plannerCanvas_scrub_readout": { + "placeholders": { + "minutes": { "type": "String" }, + "depth": { "type": "String" } + } + }, "planning_appBar_title": "Planning", "planning_card_decoCalculator_description": "Calculate no-decompression limits, required deco stops, and CNS/OTU exposure for multi-level dive profiles.", "planning_card_decoCalculator_subtitle": "Plan dives with decompression stops", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 2e8e7eae1..317d60986 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17654,6 +17654,12 @@ abstract class AppLocalizations { /// **'Welcome to Submersion'** String get onboarding_welcome_title; + /// No description provided for @plannerCanvas_scrub_readout. + /// + /// In en, this message translates to: + /// **'RT {minutes}′ · {depth}'** + String plannerCanvas_scrub_readout(String minutes, String depth); + /// No description provided for @planning_appBar_title. /// /// 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 b947adc10..478bbd7d4 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10163,6 +10163,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get onboarding_welcome_title => 'مرحباً بك في Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'التخطيط'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 5179d4c4c..fdca72d80 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10357,6 +10357,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get onboarding_welcome_title => 'Willkommen bei Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Planung'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 4e757bfd8..d102f4934 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10196,6 +10196,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboarding_welcome_title => 'Welcome to Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Planning'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 22a291493..cc8f09b6b 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10345,6 +10345,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboarding_welcome_title => 'Bienvenido a Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Planificacion'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e8c472c26..ee1b30e71 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10395,6 +10395,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboarding_welcome_title => 'Bienvenue dans Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Planification'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index d7387958d..4023ebda3 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10096,6 +10096,11 @@ class AppLocalizationsHe extends AppLocalizations { @override String get onboarding_welcome_title => 'ברוכים הבאים ל-Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'תכנון'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 14ac8b901..ab9a338c0 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10333,6 +10333,11 @@ class AppLocalizationsHu extends AppLocalizations { @override String get onboarding_welcome_title => 'Üdvözöl a Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Tervezes'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index fc2a66a59..bda1d6bcd 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10361,6 +10361,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get onboarding_welcome_title => 'Benvenuto in Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Pianificazione'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 4ac0dc97c..28e3597b3 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10284,6 +10284,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get onboarding_welcome_title => 'Welkom bij Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Planning'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 2879028ca..9c5ea8fd8 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10366,6 +10366,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get onboarding_welcome_title => 'Bem-vindo ao Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => 'Planejamento'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index a9cb695dd..d1e96989e 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9912,6 +9912,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboarding_welcome_title => '欢迎使用 Submersion'; + @override + String plannerCanvas_scrub_readout(String minutes, String depth) { + return 'RT $minutes′ · $depth'; + } + @override String get planning_appBar_title => '计划'; diff --git a/test/features/planner/plan_canvas_chart_test.dart b/test/features/planner/plan_canvas_chart_test.dart new file mode 100644 index 000000000..0c8e29b3b --- /dev/null +++ b/test/features/planner/plan_canvas_chart_test.dart @@ -0,0 +1,92 @@ +import 'package:fl_chart/fl_chart.dart'; +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_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/planner/presentation/widgets/plan_canvas_chart.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() { + group('segmentIdAtTime', () { + const gas = GasMix(o2: 21); + final segments = [ + PlanSegment.descent( + id: 'descent', + targetDepth: 30, + tankId: 't1', + gasMix: gas, + order: 0, + ), // 30/18*60 = 100 s + PlanSegment.bottom( + id: 'bottom', + depth: 30, + durationMinutes: 20, + tankId: 't1', + gasMix: gas, + order: 1, + ), // 1200 s + ]; + + test('maps times to the covering segment', () { + expect(segmentIdAtTime(segments, 50), 'descent'); + expect(segmentIdAtTime(segments, 600), 'bottom'); + }); + + test('returns null past the last user segment (computed ascent)', () { + expect(segmentIdAtTime(segments, 5000), isNull); + }); + + test('empty segments yield null', () { + expect(segmentIdAtTime(const [], 10), isNull); + }); + }); + + group('PlanCanvasChart widget', () { + Widget harness() => testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: const SizedBox(width: 400, height: 300, child: PlanCanvasChart()), + ); + + testWidgets('renders the empty state with a quick-plan action', ( + tester, + ) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + expect(find.byType(LineChart), findsNothing); + expect(find.byIcon(Icons.auto_awesome), findsOneWidget); + }); + + testWidgets('renders a LineChart once a plan exists', (tester) async { + await tester.pumpWidget(harness()); + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanCanvasChart)), + ); + container + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 30, bottomTimeMinutes: 20); + await tester.pumpAndSettle(); + + expect(find.byType(LineChart), findsOneWidget); + }); + }); +} From 7ce0445304bf1789fbff176afa52dc26b4fda5fc Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 12:07:15 -0400 Subject: [PATCH 4/8] feat(planner): status chips and results sheet on PlanOutcome --- .../widgets/plan_results_sheet.dart | 319 ++++++++++++++++++ .../widgets/plan_status_chips.dart | 155 +++++++++ lib/l10n/arb/app_en.arb | 37 ++ lib/l10n/arb/app_localizations.dart | 66 ++++ lib/l10n/arb/app_localizations_ar.dart | 50 +++ lib/l10n/arb/app_localizations_de.dart | 50 +++ lib/l10n/arb/app_localizations_en.dart | 50 +++ lib/l10n/arb/app_localizations_es.dart | 50 +++ lib/l10n/arb/app_localizations_fr.dart | 50 +++ lib/l10n/arb/app_localizations_he.dart | 50 +++ lib/l10n/arb/app_localizations_hu.dart | 50 +++ lib/l10n/arb/app_localizations_it.dart | 50 +++ lib/l10n/arb/app_localizations_nl.dart | 50 +++ lib/l10n/arb/app_localizations_pt.dart | 50 +++ lib/l10n/arb/app_localizations_zh.dart | 50 +++ .../planner/plan_results_widgets_test.dart | 63 ++++ 16 files changed, 1190 insertions(+) create mode 100644 lib/features/planner/presentation/widgets/plan_results_sheet.dart create mode 100644 lib/features/planner/presentation/widgets/plan_status_chips.dart create mode 100644 test/features/planner/plan_results_widgets_test.dart diff --git a/lib/features/planner/presentation/widgets/plan_results_sheet.dart b/lib/features/planner/presentation/widgets/plan_results_sheet.dart new file mode 100644 index 000000000..d220a566a --- /dev/null +++ b/lib/features/planner/presentation/widgets/plan_results_sheet.dart @@ -0,0 +1,319 @@ +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_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/planner/presentation/widgets/plan_status_chips.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Localized, unit-aware message for a plan issue. Reuses the existing +/// `divePlanner_warning_*` strings where a type overlaps, so only genuinely +/// new issue types need new keys. +String planIssueMessage( + BuildContext context, + PlanIssue issue, + UnitFormatter units, +) { + final l10n = context.l10n; + switch (issue.type) { + case PlanIssueType.ppO2High: + return l10n.divePlanner_warning_ppO2High( + issue.value?.toStringAsFixed(2) ?? '--', + ); + case PlanIssueType.ppO2Critical: + return l10n.divePlanner_warning_ppO2Critical( + issue.value?.toStringAsFixed(2) ?? '--', + ); + case PlanIssueType.hypoxicGas: + return l10n.plannerCanvas_issue_hypoxic( + units.formatDepth(issue.atDepth ?? 0, decimals: 0), + issue.value?.toStringAsFixed(2) ?? '--', + ); + case PlanIssueType.endExceeded: + return l10n.divePlanner_warning_endHighWithDepth( + units.formatDepth(issue.value ?? 0, decimals: 0), + ); + case PlanIssueType.gasDensityHigh: + return l10n.plannerCanvas_issue_gasDensityHigh( + issue.value?.toStringAsFixed(1) ?? '--', + ); + case PlanIssueType.gasDensityCritical: + return l10n.plannerCanvas_issue_gasDensityCritical( + issue.value?.toStringAsFixed(1) ?? '--', + ); + case PlanIssueType.cnsWarning: + return l10n.divePlanner_warning_cnsWarning( + issue.value?.toStringAsFixed(0) ?? '--', + ); + case PlanIssueType.cnsCritical: + return l10n.divePlanner_warning_cnsCritical; + case PlanIssueType.otuHigh: + return l10n.divePlanner_warning_otuWarning; + case PlanIssueType.gasReserveViolation: + return l10n.divePlanner_warning_gasLow( + units.formatPressure(issue.threshold ?? 0), + ); + case PlanIssueType.gasOut: + return l10n.divePlanner_warning_gasOut; + case PlanIssueType.ndlExceededNoDecoGas: + return l10n.plannerCanvas_issue_noDecoGas; + } +} + +IconData _issueIcon(PlanIssueSeverity severity) { + switch (severity) { + case PlanIssueSeverity.critical: + return Icons.error; + case PlanIssueSeverity.alert: + return Icons.warning; + case PlanIssueSeverity.warning: + return Icons.warning_amber; + case PlanIssueSeverity.info: + return Icons.info_outline; + } +} + +/// The results content: runtime table, per-tank gas plan, and the +/// severity-sorted issue list. The page owns the sheet chrome and passes the +/// [controller] so the sheet scrolls as one. +class PlanResultsSheet extends ConsumerWidget { + const PlanResultsSheet({super.key, required this.controller}); + + final ScrollController controller; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final outcome = ref.watch(planOutcomeProvider); + final state = ref.watch(divePlanNotifierProvider); + final settings = ref.watch(settingsProvider); + final units = UnitFormatter(settings); + final theme = Theme.of(context); + + String tankLabel(String? tankId) { + final tank = state.tanks.where((t) => t.id == tankId).firstOrNull; + if (tank == null) return '--'; + return tank.name ?? tank.gasMix.name; + } + + return ListView( + controller: controller, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _grip(theme), + _SectionHeader(context.l10n.divePlanner_label_decoSchedule), + _RuntimeTable(outcome: outcome, units: units), + const SizedBox(height: 20), + _SectionHeader(context.l10n.divePlanner_label_gasConsumption), + for (final usage in outcome.tankUsages) + _GasRow(usage: usage, label: tankLabel(usage.tankId), units: units), + const SizedBox(height: 20), + _SectionHeader(context.l10n.divePlanner_label_warnings), + if (outcome.issues.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + context.l10n.divePlanner_label_empty, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ) + else + for (final issue in outcome.issues) + _IssueRow(issue: issue, units: units), + ], + ); + } + + Widget _grip(ThemeData theme) => Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: theme.colorScheme.outline.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ); +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.label); + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + label.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.outline, + letterSpacing: 0.6, + ), + ), + ); + } +} + +class _RuntimeTable extends StatelessWidget { + const _RuntimeTable({required this.outcome, required this.units}); + + final PlanOutcome outcome; + final UnitFormatter units; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + if (outcome.stops.isEmpty) { + return Text( + l10n.plannerCanvas_results_noDeco, + style: theme.textTheme.bodyMedium, + ); + } + + Widget cell(String text, {bool header = false, int flex = 1}) => Expanded( + flex: flex, + child: Text( + text, + style: header + ? theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.outline, + ) + : theme.textTheme.bodyMedium, + ), + ); + + return Column( + children: [ + Row( + children: [ + cell(l10n.plannerCanvas_table_depth, header: true), + cell(l10n.plannerCanvas_table_stop, header: true), + cell(l10n.plannerCanvas_table_runtime, header: true), + cell(l10n.plannerCanvas_table_gas, header: true, flex: 2), + ], + ), + const Divider(height: 12), + for (final stop in outcome.stops) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + cell(units.formatDepth(stop.depthMeters, decimals: 0)), + cell(_stopText(stop)), + cell( + '${((stop.arrivalRuntimeSeconds + stop.durationSeconds) / 60).ceil()}′', + ), + cell( + GasMix(o2: stop.gasFO2 * 100, he: stop.gasFHe * 100).name, + flex: 2, + ), + ], + ), + ), + ], + ); + } + + String _stopText(PlanStop stop) { + final minutes = (stop.durationSeconds / 60).ceil(); + if (stop.airBreakSeconds > 0) { + return "$minutes′ (+${(stop.airBreakSeconds / 60).ceil()}′)"; + } + return '$minutes′'; + } +} + +class _GasRow extends StatelessWidget { + const _GasRow({ + required this.usage, + required this.label, + required this.units, + }); + + final PlanTankUsage usage; + final String label; + final UnitFormatter units; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final remaining = usage.remainingPressure; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(label, style: theme.textTheme.bodyMedium)), + Text( + units.formatVolume(usage.litersUsed), + style: theme.textTheme.bodySmall, + ), + const SizedBox(width: 12), + Text( + remaining != null ? units.formatPressure(remaining) : '--', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: usage.reserveViolation + ? theme.colorScheme.error + : null, + ), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: (usage.percentUsed / 100).clamp(0.0, 1.0), + minHeight: 4, + color: usage.reserveViolation + ? theme.colorScheme.error + : theme.colorScheme.primary, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ); + } +} + +class _IssueRow extends StatelessWidget { + const _IssueRow({required this.issue, required this.units}); + + final PlanIssue issue; + final UnitFormatter units; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = planIssueSeverityColor(theme.colorScheme, issue.severity); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_issueIcon(issue.severity), size: 18, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + planIssueMessage(context, issue, units), + style: theme.textTheme.bodyMedium?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/planner/presentation/widgets/plan_status_chips.dart b/lib/features/planner/presentation/widgets/plan_status_chips.dart new file mode 100644 index 000000000..dd755be7c --- /dev/null +++ b/lib/features/planner/presentation/widgets/plan_status_chips.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.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'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Colour for a plan-issue severity (shared by chips and the results sheet). +Color planIssueSeverityColor(ColorScheme scheme, PlanIssueSeverity severity) { + switch (severity) { + case PlanIssueSeverity.critical: + return scheme.error; + case PlanIssueSeverity.alert: + case PlanIssueSeverity.warning: + return Colors.orange; + case PlanIssueSeverity.info: + return scheme.outline; + } +} + +/// A small pill chip; tinted and tappable when [tint]/[onTap] are supplied. +class PlanChip extends StatelessWidget { + const PlanChip({ + super.key, + required this.label, + this.value, + this.tint, + this.emphasized = false, + this.onTap, + }); + + final String label; + final String? value; + final Color? tint; + final bool emphasized; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final background = tint != null + ? tint!.withValues(alpha: 0.15) + : emphasized + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest; + final border = tint != null + ? tint!.withValues(alpha: 0.6) + : Colors.transparent; + + final chip = Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: tint ?? theme.colorScheme.outline, + fontWeight: emphasized ? FontWeight.w600 : null, + ), + ), + if (value != null) ...[ + const SizedBox(width: 5), + Text( + value!, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: tint, + ), + ), + ], + ], + ), + ); + + if (onTap == null) return chip; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: chip, + ); + } +} + +/// The always-visible headline numbers above the segment list: runtime, +/// NDL/TTS, deco time, CNS, and a tappable issue count. +class PlanStatusChips extends ConsumerWidget { + const PlanStatusChips({super.key, required this.onIssuesTap}); + + /// Opens the results sheet to the issues section. + final VoidCallback onIssuesTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final outcome = ref.watch(planOutcomeProvider); + final cnsThreshold = ref.watch(cnsWarningThresholdProvider); + final theme = Theme.of(context); + + String minutes(int seconds) => '${(seconds / 60).ceil()}′'; + + final inDeco = outcome.ndlAtBottom < 0; + final maxSeverity = outcome.issues.isEmpty + ? null + : outcome.issues + .map((i) => i.severity) + .reduce((a, b) => a.index >= b.index ? a : b); + + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + PlanChip( + label: context.l10n.divePlanner_label_runtime, + value: minutes(outcome.runtimeSeconds), + emphasized: true, + ), + if (inDeco) + PlanChip( + label: context.l10n.divePlanner_label_tts, + value: minutes(outcome.ttsAtBottom), + ) + else + PlanChip( + label: context.l10n.divePlanner_label_ndl, + value: minutes(outcome.ndlAtBottom.clamp(0, 1 << 30)), + ), + if (outcome.totalDecoSeconds > 0) + PlanChip( + label: context.l10n.divePlanner_label_deco, + value: minutes(outcome.totalDecoSeconds), + ), + PlanChip( + label: context.l10n.plannerCanvas_chip_cns( + outcome.cnsEnd.toStringAsFixed(0), + ), + tint: outcome.cnsEnd >= cnsThreshold ? Colors.orange : null, + ), + if (maxSeverity != null) + PlanChip( + label: context.l10n.plannerCanvas_chip_issues( + outcome.issues.length, + ), + tint: planIssueSeverityColor(theme.colorScheme, maxSeverity), + onTap: onIssuesTap, + ), + ], + ); + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 931ddbdc4..520f7cc8f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5635,6 +5635,39 @@ } } }, + "plannerCanvas_chip_cns": "CNS {value}%", + "@plannerCanvas_chip_cns": { + "placeholders": { + "value": { "type": "String" } + } + }, + "plannerCanvas_chip_issues": "{count, plural, =1{1 issue} other{{count} issues}}", + "@plannerCanvas_chip_issues": { + "placeholders": { + "count": { "type": "int" } + } + }, + "plannerCanvas_issue_gasDensityCritical": "Gas density {value} g/L over hard limit", + "@plannerCanvas_issue_gasDensityCritical": { + "placeholders": { + "value": { "type": "String" } + } + }, + "plannerCanvas_issue_gasDensityHigh": "Gas density {value} g/L over recommended limit", + "@plannerCanvas_issue_gasDensityHigh": { + "placeholders": { + "value": { "type": "String" } + } + }, + "plannerCanvas_issue_hypoxic": "Hypoxic gas at {depth} (ppO₂ {value} bar)", + "@plannerCanvas_issue_hypoxic": { + "placeholders": { + "depth": { "type": "String" }, + "value": { "type": "String" } + } + }, + "plannerCanvas_issue_noDecoGas": "Decompression required but no deco gas carried", + "plannerCanvas_results_noDeco": "No decompression required", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "@plannerCanvas_scrub_readout": { "placeholders": { @@ -5642,6 +5675,10 @@ "depth": { "type": "String" } } }, + "plannerCanvas_table_depth": "Depth", + "plannerCanvas_table_gas": "Gas", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Stop", "planning_appBar_title": "Planning", "planning_card_decoCalculator_description": "Calculate no-decompression limits, required deco stops, and CNS/OTU exposure for multi-level dive profiles.", "planning_card_decoCalculator_subtitle": "Plan dives with decompression stops", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 317d60986..92c0aed22 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17654,12 +17654,78 @@ abstract class AppLocalizations { /// **'Welcome to Submersion'** String get onboarding_welcome_title; + /// No description provided for @plannerCanvas_chip_cns. + /// + /// In en, this message translates to: + /// **'CNS {value}%'** + String plannerCanvas_chip_cns(String value); + + /// No description provided for @plannerCanvas_chip_issues. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 issue} other{{count} issues}}'** + String plannerCanvas_chip_issues(int count); + + /// No description provided for @plannerCanvas_issue_gasDensityCritical. + /// + /// In en, this message translates to: + /// **'Gas density {value} g/L over hard limit'** + String plannerCanvas_issue_gasDensityCritical(String value); + + /// No description provided for @plannerCanvas_issue_gasDensityHigh. + /// + /// In en, this message translates to: + /// **'Gas density {value} g/L over recommended limit'** + String plannerCanvas_issue_gasDensityHigh(String value); + + /// No description provided for @plannerCanvas_issue_hypoxic. + /// + /// In en, this message translates to: + /// **'Hypoxic gas at {depth} (ppO₂ {value} bar)'** + String plannerCanvas_issue_hypoxic(String depth, String value); + + /// No description provided for @plannerCanvas_issue_noDecoGas. + /// + /// In en, this message translates to: + /// **'Decompression required but no deco gas carried'** + String get plannerCanvas_issue_noDecoGas; + + /// No description provided for @plannerCanvas_results_noDeco. + /// + /// In en, this message translates to: + /// **'No decompression required'** + String get plannerCanvas_results_noDeco; + /// No description provided for @plannerCanvas_scrub_readout. /// /// In en, this message translates to: /// **'RT {minutes}′ · {depth}'** String plannerCanvas_scrub_readout(String minutes, String depth); + /// No description provided for @plannerCanvas_table_depth. + /// + /// In en, this message translates to: + /// **'Depth'** + String get plannerCanvas_table_depth; + + /// No description provided for @plannerCanvas_table_gas. + /// + /// In en, this message translates to: + /// **'Gas'** + String get plannerCanvas_table_gas; + + /// No description provided for @plannerCanvas_table_runtime. + /// + /// In en, this message translates to: + /// **'RT'** + String get plannerCanvas_table_runtime; + + /// No description provided for @plannerCanvas_table_stop. + /// + /// In en, this message translates to: + /// **'Stop'** + String get plannerCanvas_table_stop; + /// No description provided for @planning_appBar_title. /// /// 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 478bbd7d4..c952a78c0 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10163,11 +10163,61 @@ class AppLocalizationsAr extends AppLocalizations { @override String get onboarding_welcome_title => 'مرحباً بك في Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'التخطيط'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index fdca72d80..09de91d1d 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10357,11 +10357,61 @@ class AppLocalizationsDe extends AppLocalizations { @override String get onboarding_welcome_title => 'Willkommen bei Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Planung'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index d102f4934..214e5bcd1 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10196,11 +10196,61 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboarding_welcome_title => 'Welcome to Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Planning'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index cc8f09b6b..0748c97bf 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10345,11 +10345,61 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboarding_welcome_title => 'Bienvenido a Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Planificacion'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index ee1b30e71..e8f587b54 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10395,11 +10395,61 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboarding_welcome_title => 'Bienvenue dans Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Planification'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 4023ebda3..03404ae85 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10096,11 +10096,61 @@ class AppLocalizationsHe extends AppLocalizations { @override String get onboarding_welcome_title => 'ברוכים הבאים ל-Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'תכנון'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index ab9a338c0..d58aa09e3 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10333,11 +10333,61 @@ class AppLocalizationsHu extends AppLocalizations { @override String get onboarding_welcome_title => 'Üdvözöl a Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Tervezes'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index bda1d6bcd..3f4722250 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10361,11 +10361,61 @@ class AppLocalizationsIt extends AppLocalizations { @override String get onboarding_welcome_title => 'Benvenuto in Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Pianificazione'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 28e3597b3..1e6a1a9f7 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10284,11 +10284,61 @@ class AppLocalizationsNl extends AppLocalizations { @override String get onboarding_welcome_title => 'Welkom bij Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Planning'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 9c5ea8fd8..845ab1fe8 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10366,11 +10366,61 @@ class AppLocalizationsPt extends AppLocalizations { @override String get onboarding_welcome_title => 'Bem-vindo ao Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => 'Planejamento'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index d1e96989e..3eea84fc9 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9912,11 +9912,61 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboarding_welcome_title => '欢迎使用 Submersion'; + @override + String plannerCanvas_chip_cns(String value) { + return 'CNS $value%'; + } + + @override + String plannerCanvas_chip_issues(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count issues', + one: '1 issue', + ); + return '$_temp0'; + } + + @override + String plannerCanvas_issue_gasDensityCritical(String value) { + return 'Gas density $value g/L over hard limit'; + } + + @override + String plannerCanvas_issue_gasDensityHigh(String value) { + return 'Gas density $value g/L over recommended limit'; + } + + @override + String plannerCanvas_issue_hypoxic(String depth, String value) { + return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + } + + @override + String get plannerCanvas_issue_noDecoGas => + 'Decompression required but no deco gas carried'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_table_depth => 'Depth'; + + @override + String get plannerCanvas_table_gas => 'Gas'; + + @override + String get plannerCanvas_table_runtime => 'RT'; + + @override + String get plannerCanvas_table_stop => 'Stop'; + @override String get planning_appBar_title => '计划'; diff --git a/test/features/planner/plan_results_widgets_test.dart b/test/features/planner/plan_results_widgets_test.dart new file mode 100644 index 000000000..2e96af219 --- /dev/null +++ b/test/features/planner/plan_results_widgets_test.dart @@ -0,0 +1,63 @@ +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_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/planner/presentation/widgets/plan_results_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); +} + +Widget _harness(Widget child) => testApp( + overrides: [settingsProvider.overrideWith((ref) => _TestSettingsNotifier())], + child: SizedBox(width: 500, height: 600, child: child), +); + +Future _seedDecoPlan(WidgetTester tester, Finder anchor) async { + final container = ProviderScope.containerOf(tester.element(anchor)); + container + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 45, bottomTimeMinutes: 25); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('PlanStatusChips shows a TTS chip and a tappable issues chip', ( + tester, + ) async { + await tester.pumpWidget(_harness(PlanStatusChips(onIssuesTap: () {}))); + await _seedDecoPlan(tester, find.byType(PlanStatusChips)); + + expect(find.text('TTS'), findsOneWidget); + expect(find.textContaining('issue'), findsOneWidget); + }); + + testWidgets('PlanResultsSheet renders runtime table, gas, and issues', ( + tester, + ) async { + await tester.pumpWidget( + _harness(PlanResultsSheet(controller: ScrollController())), + ); + await _seedDecoPlan(tester, find.byType(PlanResultsSheet)); + + // Runtime table header. + expect(find.text('Depth'), findsOneWidget); + // Gas section rendered a per-tank consumption bar. + expect(find.byType(LinearProgressIndicator), findsWidgets); + // Air at 45 m trips the critical gas-density issue. + expect(find.textContaining('g/L'), findsWidgets); + }); +} From e2263924a903c59d328aac2343702ba423a28f2f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 12:09:54 -0400 Subject: [PATCH 5/8] feat(planner): saved plans sheet with open, duplicate, and delete --- .../widgets/saved_plans_sheet.dart | 150 ++++++++++++++++++ lib/l10n/arb/app_en.arb | 10 ++ lib/l10n/arb/app_localizations.dart | 30 ++++ lib/l10n/arb/app_localizations_ar.dart | 17 ++ lib/l10n/arb/app_localizations_de.dart | 17 ++ lib/l10n/arb/app_localizations_en.dart | 17 ++ lib/l10n/arb/app_localizations_es.dart | 17 ++ lib/l10n/arb/app_localizations_fr.dart | 17 ++ lib/l10n/arb/app_localizations_he.dart | 17 ++ lib/l10n/arb/app_localizations_hu.dart | 17 ++ lib/l10n/arb/app_localizations_it.dart | 17 ++ lib/l10n/arb/app_localizations_nl.dart | 17 ++ lib/l10n/arb/app_localizations_pt.dart | 17 ++ lib/l10n/arb/app_localizations_zh.dart | 17 ++ .../planner/saved_plans_sheet_test.dart | 89 +++++++++++ 15 files changed, 466 insertions(+) create mode 100644 lib/features/planner/presentation/widgets/saved_plans_sheet.dart create mode 100644 test/features/planner/saved_plans_sheet_test.dart diff --git a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart new file mode 100644 index 000000000..c52e1063a --- /dev/null +++ b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_repository_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Present the saved-plans picker as a modal bottom sheet. +Future showSavedPlansSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => const SavedPlansSheet(), + ); +} + +/// Lists saved plans (newest first) with open, duplicate, and delete. +class SavedPlansSheet extends ConsumerWidget { + const SavedPlansSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final summaries = ref.watch(divePlanSummariesProvider); + final theme = Theme.of(context); + final units = UnitFormatter(ref.watch(settingsProvider)); + + // Render stale data during a reload rather than flashing a spinner. + final plans = summaries.valueOrNull; + + 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_saved_title, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (plans == null && summaries.isLoading) + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ) + else if (plans == null || plans.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + context.l10n.plannerCanvas_saved_empty, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: plans.length, + itemBuilder: (context, i) => + _PlanTile(summary: plans[i], units: units), + ), + ), + ], + ), + ), + ); + } +} + +class _PlanTile extends ConsumerWidget { + const _PlanTile({required this.summary, required this.units}); + + final DivePlanSummary summary; + final UnitFormatter units; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final date = MaterialLocalizations.of( + context, + ).formatShortDate(summary.updatedAt); + final subtitleParts = [ + date, + if (summary.maxDepth != null) units.formatDepth(summary.maxDepth!), + if (summary.runtimeSeconds != null) + '${(summary.runtimeSeconds! / 60).ceil()}′', + ]; + + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(summary.name), + subtitle: Text(subtitleParts.join(' · ')), + onTap: () { + context.pop(); + context.go('/planning/dive-planner/${summary.id}'); + }, + trailing: PopupMenuButton( + onSelected: (value) async { + final repository = ref.read(divePlanRepositoryProvider); + if (value == 'duplicate') { + await repository.duplicatePlan(summary.id); + } else if (value == 'delete') { + final confirmed = await _confirmDelete(context); + if (confirmed) await repository.deletePlan(summary.id); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'duplicate', + child: Text(context.l10n.plannerCanvas_saved_duplicate), + ), + PopupMenuItem( + value: 'delete', + child: Text(context.l10n.common_action_delete), + ), + ], + ), + ); + } + + Future _confirmDelete(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.plannerCanvas_saved_deleteConfirmTitle), + content: Text( + context.l10n.plannerCanvas_saved_deleteConfirmBody(summary.name), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(context.l10n.common_action_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(context.l10n.common_action_delete), + ), + ], + ), + ); + return result ?? false; + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 520f7cc8f..42f30a423 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5668,6 +5668,16 @@ }, "plannerCanvas_issue_noDecoGas": "Decompression required but no deco gas carried", "plannerCanvas_results_noDeco": "No decompression required", + "plannerCanvas_saved_deleteConfirmBody": "Permanently delete \"{name}\"?", + "@plannerCanvas_saved_deleteConfirmBody": { + "placeholders": { + "name": { "type": "String" } + } + }, + "plannerCanvas_saved_deleteConfirmTitle": "Delete plan?", + "plannerCanvas_saved_duplicate": "Duplicate", + "plannerCanvas_saved_empty": "No saved plans yet", + "plannerCanvas_saved_title": "Saved plans", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", "@plannerCanvas_scrub_readout": { "placeholders": { diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 92c0aed22..64a3628ce 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17696,6 +17696,36 @@ abstract class AppLocalizations { /// **'No decompression required'** String get plannerCanvas_results_noDeco; + /// No description provided for @plannerCanvas_saved_deleteConfirmBody. + /// + /// In en, this message translates to: + /// **'Permanently delete \"{name}\"?'** + String plannerCanvas_saved_deleteConfirmBody(String name); + + /// No description provided for @plannerCanvas_saved_deleteConfirmTitle. + /// + /// In en, this message translates to: + /// **'Delete plan?'** + String get plannerCanvas_saved_deleteConfirmTitle; + + /// No description provided for @plannerCanvas_saved_duplicate. + /// + /// In en, this message translates to: + /// **'Duplicate'** + String get plannerCanvas_saved_duplicate; + + /// No description provided for @plannerCanvas_saved_empty. + /// + /// In en, this message translates to: + /// **'No saved plans yet'** + String get plannerCanvas_saved_empty; + + /// No description provided for @plannerCanvas_saved_title. + /// + /// In en, this message translates to: + /// **'Saved plans'** + String get plannerCanvas_saved_title; + /// 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 c952a78c0..049cd8397 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10201,6 +10201,23 @@ class AppLocalizationsAr extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 09de91d1d..54636b380 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10395,6 +10395,23 @@ class AppLocalizationsDe extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 214e5bcd1..39b226ffb 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10234,6 +10234,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 0748c97bf..affc99cfb 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10383,6 +10383,23 @@ class AppLocalizationsEs extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 e8f587b54..eb79126fa 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10433,6 +10433,23 @@ class AppLocalizationsFr extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 03404ae85..f455dbbe4 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10134,6 +10134,23 @@ class AppLocalizationsHe extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 d58aa09e3..98643d1c5 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10371,6 +10371,23 @@ class AppLocalizationsHu extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 3f4722250..34a7febd0 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10399,6 +10399,23 @@ class AppLocalizationsIt extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 1e6a1a9f7..f96d90b74 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10322,6 +10322,23 @@ class AppLocalizationsNl extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 845ab1fe8..9df48b93f 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10404,6 +10404,23 @@ class AppLocalizationsPt extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @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 3eea84fc9..38c688ddb 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9950,6 +9950,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get plannerCanvas_results_noDeco => 'No decompression required'; + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Permanently delete \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + + @override + String get plannerCanvas_saved_duplicate => 'Duplicate'; + + @override + String get plannerCanvas_saved_empty => 'No saved plans yet'; + + @override + String get plannerCanvas_saved_title => 'Saved plans'; + @override String plannerCanvas_scrub_readout(String minutes, String depth) { return 'RT $minutes′ · $depth'; diff --git a/test/features/planner/saved_plans_sheet_test.dart b/test/features/planner/saved_plans_sheet_test.dart new file mode 100644 index 000000000..aade78846 --- /dev/null +++ b/test/features/planner/saved_plans_sheet_test.dart @@ -0,0 +1,89 @@ +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/core/services/database_service.dart'; +import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart'; +import 'package:submersion/features/planner/presentation/widgets/saved_plans_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); +} + +DivePlan _plan(String id, String name) => DivePlan( + id: id, + name: name, + gfLow: 50, + gfHigh: 80, + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), +); + +void main() { + late DivePlanRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = DivePlanRepository(); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + Widget harness() => testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: const SavedPlansSheet(), + ); + + testWidgets('lists saved plans', (tester) async { + await repository.savePlan(_plan('a', 'Reef dive')); + await repository.savePlan(_plan('b', 'Wreck dive')); + + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + expect(find.text('Reef dive'), findsOneWidget); + expect(find.text('Wreck dive'), findsOneWidget); + }); + + testWidgets('shows empty state with no plans', (tester) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + expect(find.text('No saved plans yet'), findsOneWidget); + }); + + testWidgets('delete flow removes a plan after confirmation', (tester) async { + await repository.savePlan(_plan('a', 'Reef dive')); + + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete').last); + await tester.pumpAndSettle(); + + // Confirmation dialog. + expect(find.text('Delete plan?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(await repository.getAllPlanSummaries(), isEmpty); + }); +} From 923ad72943711009c2b04e34bfb31e8692e77d01 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 12:14:59 -0400 Subject: [PATCH 6/8] feat(planner): Live Profile Canvas page and route cutover New PlanCanvasPage (phone: chart + chips + editor list with a draggable results sheet; wide: editor column + chart + always-visible results pane) replaces the 3-tab DivePlannerPage. Deletes the old page and its deco/gas/profile panels and the plannerTabIndexProvider; reuses the settings/tank/segment editing widgets. Router now points /planning/dive-planner and :planId at the canvas. --- lib/core/router/app_router.dart | 6 +- .../presentation/pages/dive_planner_page.dart | 306 -------------- .../providers/dive_planner_providers.dart | 3 - .../widgets/deco_results_panel.dart | 390 ------------------ .../widgets/gas_results_panel.dart | 288 ------------- .../widgets/plan_profile_chart.dart | 370 ----------------- .../presentation/pages/plan_canvas_page.dart | 352 ++++++++++++++++ .../widgets/saved_plans_sheet.dart | 1 - .../planner/plan_canvas_page_test.dart | 101 +++++ 9 files changed, 456 insertions(+), 1361 deletions(-) delete mode 100644 lib/features/dive_planner/presentation/pages/dive_planner_page.dart delete mode 100644 lib/features/dive_planner/presentation/widgets/deco_results_panel.dart delete mode 100644 lib/features/dive_planner/presentation/widgets/gas_results_panel.dart delete mode 100644 lib/features/dive_planner/presentation/widgets/plan_profile_chart.dart create mode 100644 lib/features/planner/presentation/pages/plan_canvas_page.dart create mode 100644 test/features/planner/plan_canvas_page_test.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 025485597..a81970268 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -116,7 +116,7 @@ import 'package:submersion/features/dive_computer/presentation/providers/downloa import 'package:submersion/features/dive_log/presentation/providers/dive_computer_providers.dart'; import 'package:submersion/features/import_wizard/data/adapters/dive_computer_adapter.dart'; import 'package:submersion/features/dashboard/presentation/pages/dashboard_page.dart'; -import 'package:submersion/features/dive_planner/presentation/pages/dive_planner_page.dart'; +import 'package:submersion/features/planner/presentation/pages/plan_canvas_page.dart'; import 'package:submersion/features/surface_interval_tool/presentation/pages/surface_interval_tool_page.dart'; import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -206,12 +206,12 @@ final appRouterProvider = Provider((ref) { GoRoute( path: 'dive-planner', name: 'divePlanner', - builder: (context, state) => const DivePlannerPage(), + builder: (context, state) => const PlanCanvasPage(), routes: [ GoRoute( path: ':planId', name: 'editPlan', - builder: (context, state) => DivePlannerPage( + builder: (context, state) => PlanCanvasPage( planId: state.pathParameters['planId'], ), ), diff --git a/lib/features/dive_planner/presentation/pages/dive_planner_page.dart b/lib/features/dive_planner/presentation/pages/dive_planner_page.dart deleted file mode 100644 index df6875d20..000000000 --- a/lib/features/dive_planner/presentation/pages/dive_planner_page.dart +++ /dev/null @@ -1,306 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; -import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; -import 'package:submersion/features/dive_planner/presentation/widgets/deco_results_panel.dart'; -import 'package:submersion/features/dive_planner/presentation/widgets/gas_results_panel.dart'; -import 'package:submersion/features/dive_planner/presentation/widgets/plan_profile_chart.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'; -import 'package:submersion/features/dive_planner/presentation/widgets/segment_list.dart'; -import 'package:submersion/features/dive_planner/presentation/widgets/simple_plan_dialog.dart'; -import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; - -/// Main dive planner page with three tabs: Plan, Results, Profile. -/// -/// The Plan tab contains: -/// - Settings panel (GF, SAC, site) -/// - Tank list (add/edit/remove tanks) -/// - Segment list (add/edit/remove/reorder segments) -/// -/// The Results tab contains: -/// - Decompression info (NDL, TTS, ceiling, deco stops) -/// - Gas consumption projections -/// - Warnings and alerts -/// -/// The Profile tab shows: -/// - Visual preview of the planned dive profile -class DivePlannerPage extends ConsumerStatefulWidget { - /// Optional plan ID to load for editing. - final String? planId; - - const DivePlannerPage({super.key, this.planId}); - - @override - ConsumerState createState() => _DivePlannerPageState(); -} - -class _DivePlannerPageState extends ConsumerState - with SingleTickerProviderStateMixin { - late TabController _tabController; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 3, vsync: this); - - // Sync tab controller with provider - _tabController.addListener(() { - if (!_tabController.indexIsChanging) { - ref.read(plannerTabIndexProvider.notifier).state = _tabController.index; - } - }); - - // Load a persisted plan when routed with an id (microtask: Riverpod 3 - // forbids provider mutation during widget lifecycle callbacks). - final planId = widget.planId; - if (planId != null) { - Future.microtask(() { - if (!mounted) return; - ref.read(divePlanNotifierProvider.notifier).loadPlanById(planId); - }); - } - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final planState = ref.watch(divePlanNotifierProvider); - final results = ref.watch(planResultsProvider); - final hasWarnings = ref.watch(planHasWarningsProvider); - - return Scaffold( - appBar: AppBar( - title: Text(planState.name), - actions: [ - // Quick add button - IconButton( - icon: const Icon(Icons.add_chart), - tooltip: context.l10n.divePlanner_action_quickPlan, - onPressed: () => _showSimplePlanDialog(context), - ), - // Save button - IconButton( - icon: Icon(planState.isDirty ? Icons.save : Icons.save_outlined), - tooltip: context.l10n.divePlanner_action_savePlan, - onPressed: planState.isDirty ? _savePlan : null, - ), - // Convert to dive button - PopupMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: context.l10n.divePlanner_action_moreOptions, - onSelected: (value) { - switch (value) { - case 'convert': - _convertToDive(); - break; - case 'reset': - _resetPlan(); - break; - case 'rename': - _showRenameDialog(context); - break; - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'convert', - child: ListTile( - leading: const Icon(Icons.scuba_diving), - title: Text(context.l10n.divePlanner_action_convertToDive), - contentPadding: EdgeInsets.zero, - ), - ), - PopupMenuItem( - value: 'rename', - child: ListTile( - leading: const Icon(Icons.edit), - title: Text(context.l10n.divePlanner_action_renamePlan), - contentPadding: EdgeInsets.zero, - ), - ), - PopupMenuItem( - value: 'reset', - child: ListTile( - leading: const Icon(Icons.refresh), - title: Text(context.l10n.divePlanner_action_resetPlan), - contentPadding: EdgeInsets.zero, - ), - ), - ], - ), - ], - bottom: TabBar( - controller: _tabController, - tabs: [ - Tab( - text: context.l10n.divePlanner_tab_plan, - icon: const Icon(Icons.edit_note), - ), - Tab( - text: context.l10n.divePlanner_tab_results, - icon: Semantics( - label: hasWarnings - ? context.l10n.divePlanner_label_resultsWithWarnings( - results.warnings.length, - ) - : context.l10n.divePlanner_tab_results, - child: Badge( - isLabelVisible: hasWarnings, - label: Text('${results.warnings.length}'), - child: const Icon(Icons.analytics), - ), - ), - ), - Tab( - text: context.l10n.divePlanner_tab_profile, - icon: const Icon(Icons.show_chart), - ), - ], - ), - ), - body: TabBarView( - controller: _tabController, - children: [_buildPlanTab(), _buildResultsTab(), _buildProfileTab()], - ), - ); - } - - Widget _buildPlanTab() { - return const SingleChildScrollView( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - PlanSettingsPanel(), - SizedBox(height: 16), - PlanTankList(), - SizedBox(height: 16), - SegmentList(), - ], - ), - ); - } - - Widget _buildResultsTab() { - return const SingleChildScrollView( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [DecoResultsPanel(), SizedBox(height: 16), GasResultsPanel()], - ), - ); - } - - Widget _buildProfileTab() { - return const Padding( - padding: EdgeInsets.all(16), - child: PlanProfileChart(), - ); - } - - void _showSimplePlanDialog(BuildContext context) { - showDialog( - context: context, - builder: (context) => const SimplePlanDialog(), - ); - } - - Future _savePlan() async { - final results = ref.read(planResultsProvider); - await ref - .read(divePlanNotifierProvider.notifier) - .save( - summary: PlanSummaryData( - maxDepth: results.maxDepth, - runtimeSeconds: results.totalRuntime, - ttsSeconds: results.ttsAtBottom, - ), - ); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.divePlanner_message_planSaved)), - ); - } - - void _convertToDive() { - final isValid = ref.read(planIsValidProvider); - if (!isValid) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.divePlanner_error_cannotConvert), - backgroundColor: Colors.red, - ), - ); - return; - } - - // TODO: Implement convert to dive - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.divePlanner_message_convertingPlan)), - ); - } - - void _resetPlan() { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(context.l10n.divePlanner_action_resetPlan), - content: Text(context.l10n.divePlanner_message_resetConfirmation), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.common_action_cancel), - ), - FilledButton( - onPressed: () { - ref.read(divePlanNotifierProvider.notifier).newPlan(); - Navigator.pop(dialogContext); - }, - child: Text(context.l10n.divePlanner_action_reset), - ), - ], - ), - ); - } - - void _showRenameDialog(BuildContext context) { - final planState = ref.read(divePlanNotifierProvider); - final controller = TextEditingController(text: planState.name); - - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(context.l10n.divePlanner_action_renamePlan), - content: TextField( - controller: controller, - decoration: InputDecoration( - labelText: context.l10n.divePlanner_field_planName, - ), - autofocus: true, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.common_action_cancel), - ), - FilledButton( - onPressed: () { - ref - .read(divePlanNotifierProvider.notifier) - .updateName(controller.text); - Navigator.pop(dialogContext); - }, - child: Text(context.l10n.common_action_save), - ), - ], - ), - ); - } -} 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 c9da4140e..4465c06d8 100644 --- a/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart +++ b/lib/features/dive_planner/presentation/providers/dive_planner_providers.dart @@ -522,8 +522,5 @@ final planIsValidProvider = Provider((ref) { /// Currently selected segment for editing. final selectedSegmentIdProvider = StateProvider((ref) => null); -/// Currently selected tab index in planner page. -final plannerTabIndexProvider = StateProvider((ref) => 0); - /// Whether the simple plan dialog is shown. final showSimplePlanDialogProvider = StateProvider((ref) => false); diff --git a/lib/features/dive_planner/presentation/widgets/deco_results_panel.dart b/lib/features/dive_planner/presentation/widgets/deco_results_panel.dart deleted file mode 100644 index 527273853..000000000 --- a/lib/features/dive_planner/presentation/widgets/deco_results_panel.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:submersion/core/providers/provider.dart'; -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/l10n/l10n_extension.dart'; - -/// Panel displaying decompression calculation results. -/// -/// Shows: -/// - Runtime summary -/// - NDL (No Decompression Limit) or deco status -/// - TTS (Time To Surface) -/// - Ceiling depth -/// - Decompression schedule (if applicable) -/// - Warnings and alerts -class DecoResultsPanel extends ConsumerWidget { - const DecoResultsPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final results = ref.watch(planResultsProvider); - final theme = Theme.of(context); - final settings = ref.watch(settingsProvider); - final units = UnitFormatter(settings); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - ExcludeSemantics( - child: Icon( - Icons.analytics, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 8), - Text( - context.l10n.divePlanner_label_decompression, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Main stats row - Row( - children: [ - Expanded( - child: _StatCard( - label: context.l10n.divePlanner_label_runtime, - value: _formatDuration(results.totalRuntime), - icon: Icons.timer, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _StatCard( - label: results.ndlAtBottom < 0 - ? context.l10n.divePlanner_label_status - : context.l10n.divePlanner_label_ndl, - value: results.ndlAtBottom < 0 - ? context.l10n.divePlanner_label_deco - : _formatDuration(results.ndlAtBottom), - icon: Icons.warning_amber, - valueColor: results.ndlAtBottom < 0 - ? Colors.orange - : Colors.green, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _StatCard( - label: context.l10n.divePlanner_label_tts, - value: _formatDuration(results.ttsAtBottom), - icon: Icons.arrow_upward, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Ceiling - if (results.maxCeiling > 0) ...[ - _InfoRow( - icon: Icons.layers, - label: context.l10n.divePlanner_label_ceiling, - value: units.formatDepth(results.maxCeiling), - ), - const SizedBox(height: 8), - ], - - // Deco schedule - if (results.decoSchedule.isNotEmpty) ...[ - const Divider(), - const SizedBox(height: 8), - Text( - context.l10n.divePlanner_label_decoSchedule, - style: theme.textTheme.labelLarge, - ), - const SizedBox(height: 8), - ...results.decoSchedule.map( - (stop) => _DecoStopRow(stop: stop, units: units), - ), - ], - - // Warnings - if (results.warnings.isNotEmpty) ...[ - const Divider(), - const SizedBox(height: 8), - Text( - context.l10n.divePlanner_label_warnings, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.error, - ), - ), - const SizedBox(height: 8), - ...results.warnings.map( - (warning) => _WarningRow(warning: warning, units: units), - ), - ], - ], - ), - ), - ); - } - - String _formatDuration(int seconds) { - if (seconds < 0) return '--'; - final minutes = seconds ~/ 60; - final secs = seconds % 60; - if (minutes >= 60) { - final hours = minutes ~/ 60; - final mins = minutes % 60; - return '${hours}h ${mins}min'; - } - if (secs == 0) return '${minutes}min'; - return '${minutes}min ${secs}s'; - } -} - -class _StatCard extends StatelessWidget { - final String label; - final String value; - final IconData icon; - final Color? valueColor; - - const _StatCard({ - required this.label, - required this.value, - required this.icon, - this.valueColor, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Semantics( - label: '$label: $value', - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - ExcludeSemantics( - child: Icon(icon, size: 20, color: theme.colorScheme.outline), - ), - const SizedBox(height: 4), - Text( - value, - style: theme.textTheme.titleMedium?.copyWith( - color: valueColor ?? theme.colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - ], - ), - ), - ); - } -} - -class _InfoRow extends StatelessWidget { - final IconData icon; - final String label; - final String value; - - const _InfoRow({ - required this.icon, - required this.label, - required this.value, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Semantics( - label: '$label: $value', - child: Row( - children: [ - ExcludeSemantics( - child: Icon(icon, size: 18, color: theme.colorScheme.outline), - ), - const SizedBox(width: 8), - Text(label, style: theme.textTheme.bodyMedium), - const Spacer(), - Text( - value, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } -} - -class _DecoStopRow extends StatelessWidget { - final DecoStop stop; - final UnitFormatter units; - - const _DecoStopRow({required this.stop, required this.units}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Semantics( - label: context.l10n.divePlanner_semantics_decoStop( - units.formatDepth(stop.depth), - stop.durationFormatted, - stop.gasMix.name, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - ExcludeSemantics( - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - const SizedBox(width: 12), - Text( - units.formatDepth(stop.depth), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 16), - Text(stop.durationFormatted, style: theme.textTheme.bodyMedium), - const Spacer(), - Text( - stop.gasMix.name, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - ], - ), - ), - ); - } -} - -class _WarningRow extends StatelessWidget { - final PlanWarning warning; - final UnitFormatter units; - - const _WarningRow({required this.warning, required this.units}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isCritical = warning.severity == PlanWarningSeverity.critical; - - final message = _formatWarningMessage(context); - - return Semantics( - label: isCritical - ? context.l10n.divePlanner_semantics_criticalWarning(message) - : context.l10n.divePlanner_semantics_warning(message), - liveRegion: true, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ExcludeSemantics( - child: Icon( - isCritical ? Icons.error : Icons.warning_amber, - size: 18, - color: isCritical ? theme.colorScheme.error : Colors.orange, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - message, - style: theme.textTheme.bodySmall?.copyWith( - color: isCritical - ? theme.colorScheme.error - : theme.colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - ); - } - - /// Format warning message with proper units. - String _formatWarningMessage(BuildContext context) { - switch (warning.type) { - case PlanWarningType.ppO2High: - // ppO2 is universally expressed in bar (partial pressure) - return context.l10n.divePlanner_warning_ppO2High( - warning.value?.toStringAsFixed(2) ?? '--', - ); - case PlanWarningType.ppO2Critical: - return context.l10n.divePlanner_warning_ppO2Critical( - warning.value?.toStringAsFixed(2) ?? '--', - ); - case PlanWarningType.gasLow: - final threshold = warning.threshold ?? 50; - return context.l10n.divePlanner_warning_gasLow( - units.formatPressure(threshold), - ); - case PlanWarningType.gasOut: - return context.l10n.divePlanner_warning_gasOut; - case PlanWarningType.ndlExceeded: - return context.l10n.divePlanner_warning_ndlExceeded; - case PlanWarningType.cnsWarning: - return context.l10n.divePlanner_warning_cnsWarning( - warning.threshold?.toStringAsFixed(0) ?? '80', - ); - case PlanWarningType.cnsCritical: - return context.l10n.divePlanner_warning_cnsCritical; - case PlanWarningType.otuWarning: - return context.l10n.divePlanner_warning_otuWarning; - case PlanWarningType.ascentRateHigh: - final rate = warning.value; - if (rate != null) { - return context.l10n.divePlanner_warning_ascentRateHighWithRate( - units.formatDepth(rate), - ); - } - return context.l10n.divePlanner_warning_ascentRateHigh; - case PlanWarningType.endHigh: - final end = warning.value; - if (end != null) { - return context.l10n.divePlanner_warning_endHighWithDepth( - units.formatDepth(end), - ); - } - return context.l10n.divePlanner_warning_endHigh; - case PlanWarningType.minGasViolation: - return context.l10n.divePlanner_warning_minGasViolation; - case PlanWarningType.modViolation: - return context.l10n.divePlanner_warning_modViolation; - } - } -} diff --git a/lib/features/dive_planner/presentation/widgets/gas_results_panel.dart b/lib/features/dive_planner/presentation/widgets/gas_results_panel.dart deleted file mode 100644 index ffd363223..000000000 --- a/lib/features/dive_planner/presentation/widgets/gas_results_panel.dart +++ /dev/null @@ -1,288 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:submersion/core/icons/mdi_icons.dart'; - -import 'package:submersion/core/providers/provider.dart'; -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/l10n/l10n_extension.dart'; - -/// Panel displaying gas consumption projections for all tanks. -/// -/// Shows per-tank: -/// - Gas used (liters and bar) -/// - Remaining pressure projection -/// - Percentage used visualization -/// - Reserve violation warnings -class GasResultsPanel extends ConsumerWidget { - const GasResultsPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final results = ref.watch(planResultsProvider); - final theme = Theme.of(context); - final settings = ref.watch(settingsProvider); - final units = UnitFormatter(settings); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - ExcludeSemantics( - child: Icon( - MdiIcons.divingScubaTank, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 8), - Text( - context.l10n.divePlanner_label_gasConsumption, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ], - ), - const SizedBox(height: 16), - - if (results.gasConsumptions.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.divePlanner_message_addSegmentsForGas, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.outline, - ), - ), - ), - ) - else - ...results.gasConsumptions.map( - (consumption) => _GasConsumptionCard( - consumption: consumption, - reservePressure: ref - .watch(divePlanNotifierProvider) - .reservePressure, - units: units, - ), - ), - ], - ), - ), - ); - } -} - -class _GasConsumptionCard extends StatelessWidget { - final GasConsumption consumption; - final double reservePressure; - final UnitFormatter units; - - const _GasConsumptionCard({ - required this.consumption, - required this.reservePressure, - required this.units, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final hasWarning = consumption.reserveViolation; - final percentUsed = consumption.percentUsed.clamp(0.0, 100.0); - final tankName = consumption.tankName ?? consumption.gasMix.name; - final semanticDescription = context.l10n - .divePlanner_semantics_gasConsumption( - tankName, - units.formatPressure(consumption.gasUsedBar), - _formatRemainingPressure(context), - consumption.percentFormatted, - hasWarning ? context.l10n.divePlanner_label_belowMinReserve : '', - ); - - return Semantics( - label: semanticDescription, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: hasWarning - ? theme.colorScheme.errorContainer.withValues(alpha: 0.3) - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: hasWarning - ? Border.all(color: theme.colorScheme.error, width: 1) - : null, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Tank header - Row( - children: [ - ExcludeSemantics( - child: CircleAvatar( - radius: 14, - backgroundColor: _getGasColor(consumption.gasMix.o2), - child: Text( - consumption.gasMix.name.substring(0, 1), - style: const TextStyle(fontSize: 12, color: Colors.white), - ), - ), - ), - const SizedBox(width: 8), - Text( - consumption.tankName ?? consumption.gasMix.name, - style: theme.textTheme.titleSmall, - ), - const Spacer(), - if (hasWarning) - ExcludeSemantics( - child: Icon( - Icons.warning_amber, - color: theme.colorScheme.error, - size: 20, - ), - ), - ], - ), - const SizedBox(height: 12), - - // Progress bar - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: percentUsed / 100, - backgroundColor: theme.colorScheme.outline.withValues( - alpha: 0.2, - ), - valueColor: AlwaysStoppedAnimation( - _getProgressColor(percentUsed), - ), - minHeight: 8, - ), - ), - const SizedBox(height: 8), - - // Stats row - Row( - children: [ - // Used - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.divePlanner_label_used, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - Text( - units.formatPressure(consumption.gasUsedBar), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - // Remaining - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - context.l10n.divePlanner_label_remaining, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - Text( - _formatRemainingPressure(context), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - color: hasWarning ? theme.colorScheme.error : null, - ), - ), - ], - ), - ), - // Percentage - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - context.l10n.divePlanner_label_consumption, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - Text( - consumption.percentFormatted, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - - // Reserve warning - if (hasWarning) ...[ - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.error_outline, - size: 16, - color: theme.colorScheme.error, - ), - const SizedBox(width: 4), - Text( - context.l10n.divePlanner_warning_belowMinReserve( - units.formatPressure(reservePressure), - ), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ], - ), - ], - ], - ), - ), - ); - } - - /// Format remaining pressure with proper unit settings. - String _formatRemainingPressure(BuildContext context) { - final remaining = consumption.remainingPressure; - if (remaining == null) return '--'; - if (remaining <= 0) return context.l10n.divePlanner_label_empty; - return units.formatPressure(remaining.toDouble()); - } - - Color _getGasColor(double o2Percent) { - if (o2Percent <= 21) return Colors.grey; - if (o2Percent <= 40) return Colors.green; - if (o2Percent <= 80) return Colors.orange; - return Colors.red; - } - - Color _getProgressColor(double percent) { - if (percent < 50) return Colors.green; - if (percent < 75) return Colors.orange; - return Colors.red; - } -} diff --git a/lib/features/dive_planner/presentation/widgets/plan_profile_chart.dart b/lib/features/dive_planner/presentation/widgets/plan_profile_chart.dart deleted file mode 100644 index bb143c1f3..000000000 --- a/lib/features/dive_planner/presentation/widgets/plan_profile_chart.dart +++ /dev/null @@ -1,370 +0,0 @@ -import 'package:fl_chart/fl_chart.dart'; -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/settings/presentation/providers/settings_providers.dart'; -import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; - -/// Visual chart showing the planned dive profile. -/// -/// Displays: -/// - Depth over time graph -/// - Color-coded segments by type -/// - Deco stops visualization -/// - Gas switch markers -class PlanProfileChart extends ConsumerWidget { - const PlanProfileChart({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final profilePoints = ref.watch(planProfilePointsProvider); - final planState = ref.watch(divePlanNotifierProvider); - final theme = Theme.of(context); - - // Get unit settings for the active diver - final settings = ref.watch(settingsProvider); - final units = UnitFormatter(settings); - - if (profilePoints.isEmpty) { - return Card( - child: Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.show_chart, - size: 48, - color: theme.colorScheme.outline, - ), - const SizedBox(height: 16), - Text( - context.l10n.divePlanner_message_noProfile, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - context.l10n.divePlanner_message_addSegmentsForProfile, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - ], - ), - ), - ), - ); - } - - // Calculate axis bounds (convert to display units) - final maxDepthMeters = planState.maxDepth; - final maxDepthDisplay = units.convertDepth(maxDepthMeters); - final maxTime = planState.totalTimeSeconds / 60; // in minutes - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header with stats - Row( - children: [ - Icon(Icons.show_chart, color: theme.colorScheme.primary), - const SizedBox(width: 8), - Text( - context.l10n.divePlanner_label_diveProfile, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ), - const Spacer(), - _StatChip( - label: context.l10n.divePlanner_label_max, - value: units.formatDepth(maxDepthMeters), - ), - const SizedBox(width: 8), - _StatChip( - label: context.l10n.divePlanner_label_time, - value: '${maxTime.toStringAsFixed(0)}min', - ), - ], - ), - const SizedBox(height: 16), - - // Chart - Expanded( - child: Semantics( - label: context.l10n.divePlanner_semantics_profileChart( - units.formatDepth(planState.maxDepth), - (planState.totalTimeSeconds / 60).toStringAsFixed(0), - ), - child: LineChart( - _buildChartData( - context, - profilePoints, - maxDepthDisplay, - maxTime, - theme, - units, - ), - ), - ), - ), - - // Legend - const SizedBox(height: 16), - _buildLegend(context, theme), - ], - ), - ), - ); - } - - LineChartData _buildChartData( - BuildContext context, - List points, - double maxDepthDisplay, - double maxTime, - ThemeData theme, - UnitFormatter units, - ) { - // Convert profile points to chart spots - // Use negative depth values so deeper = lower on chart (inverted Y-axis) - final spots = points - .map( - (p) => FlSpot( - p.timestamp / 60, // x: time in minutes - -units.convertDepth(p.depth), // y: negative depth for inverted axis - ), - ) - .toList(); - - return LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: true, - horizontalInterval: _calculateInterval(maxDepthDisplay), - verticalInterval: _calculateInterval(maxTime), - getDrawingHorizontalLine: (value) => FlLine( - color: theme.colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 1, - ), - getDrawingVerticalLine: (value) => FlLine( - color: theme.colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - axisNameWidget: Text( - context.l10n.divePlanner_label_timeAxis, - style: theme.textTheme.labelSmall, - ), - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, - interval: _calculateInterval(maxTime), - getTitlesWidget: (value, meta) { - return SideTitleWidget( - meta: meta, - child: Text( - value.toStringAsFixed(0), - style: theme.textTheme.labelSmall, - ), - ); - }, - ), - ), - leftTitles: AxisTitles( - axisNameWidget: Text( - context.l10n.divePlanner_label_depthAxis(units.depthSymbol), - style: theme.textTheme.labelSmall, - ), - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - interval: _calculateInterval(maxDepthDisplay), - getTitlesWidget: (value, meta) { - // Show positive depth values (negate the negative axis values) - return SideTitleWidget( - meta: meta, - child: Text( - (-value).toStringAsFixed(0), - style: theme.textTheme.labelSmall, - ), - ); - }, - ), - ), - ), - borderData: FlBorderData( - show: true, - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - minX: 0, - maxX: maxTime > 0 ? maxTime * 1.05 : 10, - // Inverted Y-axis: negative max depth at bottom, 0 at top - minY: maxDepthDisplay > 0 ? -maxDepthDisplay * 1.1 : -10, - maxY: 0, - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: false, - gradient: LinearGradient( - colors: [ - theme.colorScheme.primary, - theme.colorScheme.primary.withValues(alpha: 0.7), - ], - ), - barWidth: 3, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - theme.colorScheme.primary.withValues(alpha: 0.05), - theme.colorScheme.primary.withValues(alpha: 0.3), - ], - ), - ), - ), - ], - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - // Negate the Y value back to positive for display - final depth = -spot.y; - return LineTooltipItem( - '${depth.toStringAsFixed(1)}${units.depthSymbol} @ ${spot.x.toStringAsFixed(1)}min', - TextStyle( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ); - }).toList(); - }, - ), - ), - ); - } - - double _calculateInterval(double maxValue) { - if (maxValue <= 0) return 5; - if (maxValue <= 10) return 2; - if (maxValue <= 20) return 5; - if (maxValue <= 50) return 10; - if (maxValue <= 100) return 20; - return 30; - } - - Widget _buildLegend(BuildContext context, ThemeData theme) { - return Wrap( - spacing: 16, - runSpacing: 8, - children: [ - _LegendItem( - color: Colors.blue, - label: context.l10n.divePlanner_legend_descent, - ), - _LegendItem( - color: theme.colorScheme.primary, - label: context.l10n.divePlanner_legend_bottom, - ), - _LegendItem( - color: Colors.green, - label: context.l10n.divePlanner_legend_ascent, - ), - _LegendItem( - color: Colors.orange, - label: context.l10n.divePlanner_legend_deco, - ), - _LegendItem( - color: Colors.teal, - label: context.l10n.divePlanner_legend_safety, - ), - ], - ); - } -} - -class _StatChip extends StatelessWidget { - final String label; - final String value; - - const _StatChip({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - const SizedBox(width: 4), - Text( - value, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } -} - -class _LegendItem extends StatelessWidget { - final Color color; - final String label; - - const _LegendItem({required this.color, required this.label}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 12, - height: 4, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 4), - Text(label, style: theme.textTheme.labelSmall), - ], - ); - } -} diff --git a/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart new file mode 100644 index 000000000..3c09e4e6c --- /dev/null +++ b/lib/features/planner/presentation/pages/plan_canvas_page.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/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'; +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/presentation/providers/plan_canvas_providers.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'; +import 'package:submersion/features/planner/presentation/widgets/saved_plans_sheet.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart'; + +/// The Live Profile Canvas: a chart-centric dive planner. Editing state lives +/// on [divePlanNotifierProvider]; every displayed number comes from the +/// PlanEngine via [planOutcomeProvider]. +class PlanCanvasPage extends ConsumerStatefulWidget { + const PlanCanvasPage({super.key, this.planId}); + + final String? planId; + + @override + ConsumerState createState() => _PlanCanvasPageState(); +} + +class _PlanCanvasPageState extends ConsumerState { + final _sheetController = DraggableScrollableController(); + + @override + void initState() { + super.initState(); + final planId = widget.planId; + if (planId != null) { + // Riverpod 3 forbids provider mutation during widget lifecycle + // callbacks; defer the load to a microtask. + Future.microtask(() { + if (!mounted) return; + ref.read(divePlanNotifierProvider.notifier).loadPlanById(planId); + }); + } + } + + @override + void dispose() { + _sheetController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final planState = ref.watch(divePlanNotifierProvider); + final isWide = ResponsiveBreakpoints.isDesktop(context); + + 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'), + ], + ), + ), + actions: [ + IconButton( + icon: Icon(planState.isDirty ? Icons.save : Icons.save_outlined), + tooltip: context.l10n.divePlanner_action_savePlan, + onPressed: planState.isDirty ? _savePlan : null, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: context.l10n.divePlanner_action_moreOptions, + onSelected: _onMenu, + itemBuilder: (context) => [ + _menuItem( + 'quickPlan', + Icons.auto_awesome, + context.l10n.divePlanner_action_quickPlan, + ), + _menuItem( + 'saved', + Icons.folder_open, + context.l10n.plannerCanvas_saved_title, + ), + _menuItem( + 'settings', + Icons.tune, + context.l10n.divePlanner_label_planSettings, + ), + _menuItem( + 'convert', + Icons.scuba_diving, + context.l10n.divePlanner_action_convertToDive, + ), + _menuItem( + 'reset', + Icons.refresh, + context.l10n.divePlanner_action_resetPlan, + ), + ], + ), + ], + ), + body: isWide ? _buildWide() : _buildPhone(), + ); + } + + PopupMenuItem _menuItem(String value, IconData icon, String label) { + return PopupMenuItem( + value: value, + child: ListTile( + leading: Icon(icon), + title: Text(label), + contentPadding: EdgeInsets.zero, + ), + ); + } + + void _onMenu(String value) { + switch (value) { + case 'quickPlan': + showDialog( + context: context, + builder: (_) => const SimplePlanDialog(), + ); + case 'saved': + showSavedPlansSheet(context); + case 'settings': + _showSettingsSheet(context); + case 'convert': + _convertToDive(); + case 'reset': + _resetPlan(); + } + } + + // --- Phone: chart + chips + editor list, results in a draggable sheet --- + + Widget _buildPhone() { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + Column( + children: [ + SizedBox( + height: constraints.maxHeight * 0.42, + child: const Padding( + padding: EdgeInsets.all(8), + child: PlanCanvasChart(), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: PlanStatusChips(onIssuesTap: _openResultsSheet), + ), + const SizedBox(height: 8), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 72), + children: const [SegmentList(), PlanTankList()], + ), + ), + ], + ), + DraggableScrollableSheet( + controller: _sheetController, + minChildSize: 0.08, + initialChildSize: 0.08, + maxChildSize: 0.85, + snap: true, + snapSizes: const [0.08, 0.5, 0.85], + builder: (context, scrollController) => Material( + elevation: 8, + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), + ), + child: PlanResultsSheet(controller: scrollController), + ), + ), + ], + ); + }, + ); + } + + void _openResultsSheet() { + _sheetController.animateTo( + 0.5, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + + // --- Wide: editor column, chart + chips, always-visible results pane --- + + Widget _buildWide() { + return Row( + children: [ + SizedBox( + width: 360, + child: ListView( + padding: const EdgeInsets.all(12), + children: const [ + PlanSettingsPanel(), + SizedBox(height: 12), + PlanTankList(), + SizedBox(height: 12), + SegmentList(), + ], + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: Column( + children: [ + const Expanded( + child: Padding( + padding: EdgeInsets.all(12), + child: PlanCanvasChart(), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: PlanStatusChips(onIssuesTap: () {}), + ), + SizedBox( + height: 260, + child: PlanResultsSheet(controller: ScrollController()), + ), + ], + ), + ), + ], + ); + } + + // --- Actions --- + + Future _savePlan() async { + final outcome = ref.read(planOutcomeProvider); + await ref + .read(divePlanNotifierProvider.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_planSaved)), + ); + } + + void _convertToDive() { + final isValid = ref.read(planIsValidProvider); + if (!isValid) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.divePlanner_error_cannotConvert), + backgroundColor: Colors.red, + ), + ); + return; + } + // Convert-to-dive persistence lands in Phase 6 (log integration). + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.divePlanner_message_convertingPlan)), + ); + } + + void _showSettingsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => const SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.all(16), + child: PlanSettingsPanel(), + ), + ), + ); + } + + void _resetPlan() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.divePlanner_action_resetPlan), + content: Text(context.l10n.divePlanner_message_resetConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_action_cancel), + ), + FilledButton( + onPressed: () { + ref.read(divePlanNotifierProvider.notifier).newPlan(); + Navigator.pop(dialogContext); + }, + child: Text(context.l10n.divePlanner_action_reset), + ), + ], + ), + ); + } + + void _showRenameDialog(BuildContext context) { + final controller = TextEditingController( + text: ref.read(divePlanNotifierProvider).name, + ); + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.divePlanner_action_renamePlan), + content: TextField( + controller: controller, + decoration: InputDecoration( + labelText: context.l10n.divePlanner_field_planName, + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_action_cancel), + ), + FilledButton( + onPressed: () { + ref + .read(divePlanNotifierProvider.notifier) + .updateName(controller.text); + Navigator.pop(dialogContext); + }, + child: Text(context.l10n.common_action_save), + ), + ], + ), + ); + } +} diff --git a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart index c52e1063a..fbbd135e8 100644 --- a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart +++ b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart @@ -82,7 +82,6 @@ class _PlanTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); final date = MaterialLocalizations.of( context, ).formatShortDate(summary.updatedAt); diff --git a/test/features/planner/plan_canvas_page_test.dart b/test/features/planner/plan_canvas_page_test.dart new file mode 100644 index 000000000..2919bd51c --- /dev/null +++ b/test/features/planner/plan_canvas_page_test.dart @@ -0,0 +1,101 @@ +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/core/services/database_service.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/segment_list.dart'; +import 'package:submersion/features/planner/presentation/pages/plan_canvas_page.dart'; +import 'package:submersion/features/planner/presentation/widgets/plan_canvas_chart.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'; +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(); + }); + + Widget harness() => testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: const PlanCanvasPage(), + ); + + Future setSize(WidgetTester tester, Size size) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + void seed(WidgetTester tester) { + ProviderScope.containerOf(tester.element(find.byType(PlanCanvasPage))) + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 30, bottomTimeMinutes: 20); + } + + testWidgets('phone layout shows chart, chips, segments, and a sheet', ( + tester, + ) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + seed(tester); + await tester.pumpAndSettle(); + + expect(find.byType(PlanCanvasChart), findsOneWidget); + expect(find.byType(PlanStatusChips), findsOneWidget); + expect(find.byType(SegmentList), findsOneWidget); + expect(find.byType(DraggableScrollableSheet), findsOneWidget); + }); + + testWidgets('wide layout shows settings panel and no draggable sheet', ( + tester, + ) async { + await setSize(tester, const Size(1400, 900)); + await tester.pumpWidget(harness()); + seed(tester); + await tester.pumpAndSettle(); + + expect(find.byType(PlanSettingsPanel), findsOneWidget); + expect(find.byType(DraggableScrollableSheet), findsNothing); + }); + + testWidgets('save action clears the dirty flag', (tester) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanCanvasPage)), + ); + container + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 30, bottomTimeMinutes: 20); + await tester.pumpAndSettle(); + expect(container.read(divePlanNotifierProvider).isDirty, isTrue); + + await tester.tap(find.byIcon(Icons.save)); + await tester.pumpAndSettle(); + + expect(container.read(divePlanNotifierProvider).isDirty, isFalse); + }); +} From c88bd7af1c32b82ea2eeb28993bae8947375212a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 12:19:04 -0400 Subject: [PATCH 7/8] feat(planner): localize the canvas UI in all locales Translated the 17 new plannerCanvas_* keys into ar, de, es, fr, he, hu, it, nl, pt, zh. gen-l10n reports zero untranslated messages. --- lib/l10n/arb/app_ar.arb | 17 +++++++++++++++ lib/l10n/arb/app_de.arb | 17 +++++++++++++++ lib/l10n/arb/app_es.arb | 17 +++++++++++++++ lib/l10n/arb/app_fr.arb | 17 +++++++++++++++ lib/l10n/arb/app_he.arb | 17 +++++++++++++++ lib/l10n/arb/app_hu.arb | 17 +++++++++++++++ lib/l10n/arb/app_it.arb | 17 +++++++++++++++ lib/l10n/arb/app_localizations_ar.dart | 30 +++++++++++++------------- lib/l10n/arb/app_localizations_de.dart | 28 ++++++++++++------------ lib/l10n/arb/app_localizations_es.dart | 28 ++++++++++++------------ lib/l10n/arb/app_localizations_fr.dart | 30 +++++++++++++------------- lib/l10n/arb/app_localizations_he.dart | 30 +++++++++++++------------- lib/l10n/arb/app_localizations_hu.dart | 30 +++++++++++++------------- lib/l10n/arb/app_localizations_it.dart | 29 +++++++++++++------------ lib/l10n/arb/app_localizations_nl.dart | 26 +++++++++++----------- lib/l10n/arb/app_localizations_pt.dart | 30 +++++++++++++------------- lib/l10n/arb/app_localizations_zh.dart | 30 ++++++++++++-------------- lib/l10n/arb/app_nl.arb | 17 +++++++++++++++ lib/l10n/arb/app_pt.arb | 17 +++++++++++++++ lib/l10n/arb/app_zh.arb | 17 +++++++++++++++ 20 files changed, 315 insertions(+), 146 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 90ddfffcd..97d2e404f 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3235,6 +3235,23 @@ "onboarding_welcome_nameValidation": "الرجاء إدخال اسمك", "onboarding_welcome_subtitle": "تسجيل وتحليل متقدم للغوص", "onboarding_welcome_title": "مرحباً بك في Submersion", + "plannerCanvas_chip_cns": "CNS {value}%", + "plannerCanvas_chip_issues": "{count, plural, =1{مشكلة واحدة} other{{count} مشكلات}}", + "plannerCanvas_issue_gasDensityCritical": "كثافة الغاز {value} g/L تتجاوز الحد الأقصى", + "plannerCanvas_issue_gasDensityHigh": "كثافة الغاز {value} g/L تتجاوز الحد الموصى به", + "plannerCanvas_issue_hypoxic": "غاز ناقص الأكسجين عند {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noDecoGas": "يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف", + "plannerCanvas_results_noDeco": "لا يلزم تخفيف الضغط", + "plannerCanvas_saved_deleteConfirmBody": "حذف \"{name}\" نهائيًا؟", + "plannerCanvas_saved_deleteConfirmTitle": "حذف الخطة؟", + "plannerCanvas_saved_duplicate": "تكرار", + "plannerCanvas_saved_empty": "لا توجد خطط محفوظة بعد", + "plannerCanvas_saved_title": "الخطط المحفوظة", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "العمق", + "plannerCanvas_table_gas": "الغاز", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "توقف", "planning_appBar_title": "التخطيط", "planning_card_decoCalculator_description": "احسب حدود عدم تخفيف الضغط، ومحطات تخفيف الضغط المطلوبة، والتعرض لـ CNS/OTU لملفات الغوص متعددة المستويات.", "planning_card_decoCalculator_subtitle": "خطط للغوصات مع محطات تخفيف الضغط", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 4d1dd28e0..921ff5e69 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3235,6 +3235,23 @@ "onboarding_welcome_nameValidation": "Bitte geben Sie Ihren Namen ein", "onboarding_welcome_subtitle": "Erweiterte Tauchprotokollierung und -analyse", "onboarding_welcome_title": "Willkommen bei Submersion", + "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", + "plannerCanvas_issue_gasDensityHigh": "Gasdichte {value} g/L über empfohlenem Limit", + "plannerCanvas_issue_hypoxic": "Hypoxisches Gas bei {depth} (ppO₂ {value} bar)", + "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?", + "plannerCanvas_saved_deleteConfirmTitle": "Plan löschen?", + "plannerCanvas_saved_duplicate": "Duplizieren", + "plannerCanvas_saved_empty": "Noch keine gespeicherten Pläne", + "plannerCanvas_saved_title": "Gespeicherte Pläne", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "Tiefe", + "plannerCanvas_table_gas": "Gas", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Stopp", "planning_appBar_title": "Planung", "planning_card_decoCalculator_description": "Berechnen Sie Nullzeitgrenzen, erforderliche Dekostopps und CNS/OTU-Belastung für mehrstufige Tauchprofile.", "planning_card_decoCalculator_subtitle": "Tauchgänge mit Dekostopps planen", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 5a54fa71f..8e73d25f0 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3235,6 +3235,23 @@ "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_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", + "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_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}»?", + "plannerCanvas_saved_deleteConfirmTitle": "¿Eliminar plan?", + "plannerCanvas_saved_duplicate": "Duplicar", + "plannerCanvas_saved_empty": "Aún no hay planes guardados", + "plannerCanvas_saved_title": "Planes guardados", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "Profundidad", + "plannerCanvas_table_gas": "Gas", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Parada", "planning_appBar_title": "Planificacion", "planning_card_decoCalculator_description": "Calcula los limites de no descompresion, las paradas de deco requeridas y la exposicion CNS/OTU para perfiles de inmersion multinivel.", "planning_card_decoCalculator_subtitle": "Planifica inmersiones con paradas de descompresion", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index b5671f7b9..1d98e2fb4 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3201,6 +3201,23 @@ "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_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", + "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_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} » ?", + "plannerCanvas_saved_deleteConfirmTitle": "Supprimer le plan ?", + "plannerCanvas_saved_duplicate": "Dupliquer", + "plannerCanvas_saved_empty": "Aucun plan enregistré", + "plannerCanvas_saved_title": "Plans enregistrés", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "Profondeur", + "plannerCanvas_table_gas": "Gaz", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Palier", "planning_appBar_title": "Planification", "planning_card_decoCalculator_description": "Calculez les limites de non-decompression, les paliers de decompression requis et l'exposition CNS/OTU pour des profils de plongee multi-niveaux.", "planning_card_decoCalculator_subtitle": "Planifiez des plongees avec paliers de decompression", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 987808122..e3df991ca 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3201,6 +3201,23 @@ "onboarding_welcome_nameValidation": "נא להזין את שמך", "onboarding_welcome_subtitle": "רישום וניתוח צלילה מתקדם", "onboarding_welcome_title": "ברוכים הבאים ל-Submersion", + "plannerCanvas_chip_cns": "CNS {value}%", + "plannerCanvas_chip_issues": "{count, plural, =1{בעיה אחת} other{{count} בעיות}}", + "plannerCanvas_issue_gasDensityCritical": "צפיפות הגז {value} g/L מעל הגבול המרבי", + "plannerCanvas_issue_gasDensityHigh": "צפיפות הגז {value} g/L מעל הגבול המומלץ", + "plannerCanvas_issue_hypoxic": "גז היפוקסי ב-{depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noDecoGas": "נדרשת דקומפרסיה אך לא נלקח גז דקו", + "plannerCanvas_results_noDeco": "לא נדרשת דקומפרסיה", + "plannerCanvas_saved_deleteConfirmBody": "למחוק לצמיתות את \"{name}\"?", + "plannerCanvas_saved_deleteConfirmTitle": "למחוק את התוכנית?", + "plannerCanvas_saved_duplicate": "שכפול", + "plannerCanvas_saved_empty": "אין עדיין תוכניות שמורות", + "plannerCanvas_saved_title": "תוכניות שמורות", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "עומק", + "plannerCanvas_table_gas": "גז", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "עצירה", "planning_appBar_title": "תכנון", "planning_card_decoCalculator_description": "חשב מגבלות ללא דקומפרסיה, עצירות דקו נדרשות וחשיפת CNS/OTU עבור פרופילי צלילה רב-שלביים.", "planning_card_decoCalculator_subtitle": "תכנן צלילות עם עצירות דקומפרסיה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 341925a22..7b6a28cec 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3201,6 +3201,23 @@ "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_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", + "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_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?", + "plannerCanvas_saved_deleteConfirmTitle": "Törli a tervet?", + "plannerCanvas_saved_duplicate": "Duplikálás", + "plannerCanvas_saved_empty": "Még nincsenek mentett tervek", + "plannerCanvas_saved_title": "Mentett tervek", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "Mélység", + "plannerCanvas_table_gas": "Gáz", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Megálló", "planning_appBar_title": "Tervezes", "planning_card_decoCalculator_description": "Szamitsa ki a dekompresszios limiteket, a szükseges deko megalloket es a CNS/OTU terhelest többszintu merülesi profilokhoz.", "planning_card_decoCalculator_subtitle": "Merülesek tervezese dekompressziós megallokkal", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 383eda7dd..e38c9ffd9 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3201,6 +3201,23 @@ "onboarding_welcome_nameValidation": "Inserisci il tuo nome", "onboarding_welcome_subtitle": "Registrazione e analisi avanzata delle immersioni", "onboarding_welcome_title": "Benvenuto in Submersion", + "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", + "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_noDecoGas": "Decompressione necessaria ma nessun gas deco a bordo", + "plannerCanvas_results_noDeco": "Nessuna decompressione necessaria", + "plannerCanvas_saved_deleteConfirmBody": "Eliminare definitivamente «{name}»?", + "plannerCanvas_saved_deleteConfirmTitle": "Eliminare il piano?", + "plannerCanvas_saved_duplicate": "Duplica", + "plannerCanvas_saved_empty": "Nessun piano salvato", + "plannerCanvas_saved_title": "Piani salvati", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "Profondità", + "plannerCanvas_table_gas": "Gas", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Sosta", "planning_appBar_title": "Pianificazione", "planning_card_decoCalculator_description": "Calcola i limiti di non decompressione, le soste deco necessarie e l'esposizione CNS/OTU per profili di immersione multilivello.", "planning_card_decoCalculator_subtitle": "Pianifica immersioni con soste di decompressione", diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 049cd8397..3b52f6957 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10173,50 +10173,50 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count مشكلات', + one: 'مشكلة واحدة', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'كثافة الغاز $value g/L تتجاوز الحد الأقصى'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'كثافة الغاز $value g/L تتجاوز الحد الموصى به'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'غاز ناقص الأكسجين عند $depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'لا يلزم تخفيف الضغط'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return 'حذف \"$name\" نهائيًا؟'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'حذف الخطة؟'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'تكرار'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'لا توجد خطط محفوظة بعد'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'الخطط المحفوظة'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10224,16 +10224,16 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'العمق'; @override - String get plannerCanvas_table_gas => 'Gas'; + String get plannerCanvas_table_gas => 'الغاز'; @override String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'توقف'; @override String get planning_appBar_title => 'التخطيط'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 54636b380..d72df86ac 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10367,50 +10367,50 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count Probleme', + one: '1 Problem', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'Gasdichte $value g/L über hartem Limit'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'Gasdichte $value g/L über empfohlenem Limit'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'Hypoxisches Gas bei $depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'Dekompression erforderlich, aber kein Deko-Gas mitgeführt'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'Keine Dekompression erforderlich'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return '„$name“ endgültig löschen?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'Plan löschen?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'Duplizieren'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'Noch keine gespeicherten Pläne'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'Gespeicherte Pläne'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10418,7 +10418,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'Tiefe'; @override String get plannerCanvas_table_gas => 'Gas'; @@ -10427,7 +10427,7 @@ class AppLocalizationsDe extends AppLocalizations { String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'Stopp'; @override String get planning_appBar_title => 'Planung'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index affc99cfb..a46542663 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10355,50 +10355,50 @@ class AppLocalizationsEs extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count problemas', + one: '1 problema', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'Densidad del gas $value g/L sobre el límite máximo'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'Densidad del gas $value g/L sobre el límite recomendado'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'Gas hipóxico a $depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'Se requiere descompresión pero no se lleva gas de deco'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'No se requiere descompresión'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return '¿Eliminar permanentemente «$name»?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => '¿Eliminar plan?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'Duplicar'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'Aún no hay planes guardados'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'Planes guardados'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10406,7 +10406,7 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'Profundidad'; @override String get plannerCanvas_table_gas => 'Gas'; @@ -10415,7 +10415,7 @@ class AppLocalizationsEs extends AppLocalizations { String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'Parada'; @override String get planning_appBar_title => 'Planificacion'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index eb79126fa..70a113107 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10405,50 +10405,50 @@ class AppLocalizationsFr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count problèmes', + one: '1 problème', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'Densité du gaz $value g/L au-dessus de la limite stricte'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'Densité du gaz $value g/L au-dessus de la limite recommandée'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'Gaz hypoxique à $depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'Décompression requise mais aucun gaz de déco emporté'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'Aucune décompression requise'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return 'Supprimer définitivement « $name » ?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'Supprimer le plan ?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'Dupliquer'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'Aucun plan enregistré'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'Plans enregistrés'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10456,16 +10456,16 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'Profondeur'; @override - String get plannerCanvas_table_gas => 'Gas'; + String get plannerCanvas_table_gas => 'Gaz'; @override String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'Palier'; @override String get planning_appBar_title => 'Planification'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index f455dbbe4..e132a8a2c 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10106,50 +10106,50 @@ class AppLocalizationsHe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count בעיות', + one: 'בעיה אחת', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'צפיפות הגז $value g/L מעל הגבול המרבי'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'צפיפות הגז $value g/L מעל הגבול המומלץ'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'גז היפוקסי ב-$depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'נדרשת דקומפרסיה אך לא נלקח גז דקו'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'לא נדרשת דקומפרסיה'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return 'למחוק לצמיתות את \"$name\"?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'למחוק את התוכנית?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'שכפול'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'אין עדיין תוכניות שמורות'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'תוכניות שמורות'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10157,16 +10157,16 @@ class AppLocalizationsHe extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'עומק'; @override - String get plannerCanvas_table_gas => 'Gas'; + String get plannerCanvas_table_gas => 'גז'; @override String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'עצירה'; @override String get planning_appBar_title => 'תכנון'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 98643d1c5..f65245223 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10343,50 +10343,50 @@ class AppLocalizationsHu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count probléma', + one: '1 probléma', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'Gázsűrűség $value g/L a szigorú határ felett'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'Gázsűrűség $value g/L az ajánlott határ felett'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'Hipoxiás gáz $depth mélységben (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'Dekompresszió szükséges, de nincs deco gáz'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'Nincs szükség dekompresszióra'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return 'Véglegesen törli a(z) „$name” tervet?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'Törli a tervet?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'Duplikálás'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'Még nincsenek mentett tervek'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'Mentett tervek'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10394,16 +10394,16 @@ class AppLocalizationsHu extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'Mélység'; @override - String get plannerCanvas_table_gas => 'Gas'; + String get plannerCanvas_table_gas => 'Gáz'; @override String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'Megálló'; @override String get planning_appBar_title => 'Tervezes'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 34a7febd0..6cb4ba2cf 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10371,50 +10371,51 @@ class AppLocalizationsIt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count problemi', + one: '1 problema', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'Densità del gas $value g/L oltre il limite massimo'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'Densità del gas $value g/L oltre il limite consigliato'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'Gas ipossico a $depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'Decompressione necessaria ma nessun gas deco a bordo'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => + 'Nessuna decompressione necessaria'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return 'Eliminare definitivamente «$name»?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'Eliminare il piano?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'Duplica'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'Nessun piano salvato'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'Piani salvati'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10422,7 +10423,7 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'Profondità'; @override String get plannerCanvas_table_gas => 'Gas'; @@ -10431,7 +10432,7 @@ class AppLocalizationsIt extends AppLocalizations { String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'Sosta'; @override String get planning_appBar_title => 'Pianificazione'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index f96d90b74..3ee9964c3 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10294,50 +10294,50 @@ class AppLocalizationsNl extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count problemen', + one: '1 probleem', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'Gasdichtheid $value g/L boven harde limiet'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'Gasdichtheid $value g/L boven aanbevolen limiet'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'Hypoxisch gas op $depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'Decompressie vereist maar geen decogas meegenomen'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'Geen decompressie vereist'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return '\'$name\' definitief verwijderen?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'Plan verwijderen?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'Dupliceren'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'Nog geen opgeslagen plannen'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'Opgeslagen plannen'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10345,7 +10345,7 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'Diepte'; @override String get plannerCanvas_table_gas => 'Gas'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 9df48b93f..19f3d5958 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10376,50 +10376,50 @@ class AppLocalizationsPt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count problemas', + one: '1 problema', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return 'Densidade do gás $value g/L acima do limite máximo'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return 'Densidade do gás $value g/L acima do limite recomendado'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return 'Gás hipóxico a $depth (ppO₂ $value bar)'; } @override String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + 'Descompressão necessária mas sem gás de deco a bordo'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => 'Nenhuma descompressão necessária'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return 'Excluir permanentemente \"$name\"?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => 'Excluir plano?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => 'Duplicar'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => 'Ainda não há planos salvos'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => 'Planos salvos'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -10427,16 +10427,16 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => 'Profundidade'; @override - String get plannerCanvas_table_gas => 'Gas'; + String get plannerCanvas_table_gas => 'Gás'; @override String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => 'Parada'; @override String get planning_appBar_title => 'Planejamento'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 38c688ddb..18526e426 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9922,50 +9922,48 @@ class AppLocalizationsZh extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count issues', - one: '1 issue', + other: '$count 个问题', ); return '$_temp0'; } @override String plannerCanvas_issue_gasDensityCritical(String value) { - return 'Gas density $value g/L over hard limit'; + return '气体密度 $value g/L 超过硬性上限'; } @override String plannerCanvas_issue_gasDensityHigh(String value) { - return 'Gas density $value g/L over recommended limit'; + return '气体密度 $value g/L 超过建议上限'; } @override String plannerCanvas_issue_hypoxic(String depth, String value) { - return 'Hypoxic gas at $depth (ppO₂ $value bar)'; + return '$depth 处为低氧气体(ppO₂ $value bar)'; } @override - String get plannerCanvas_issue_noDecoGas => - 'Decompression required but no deco gas carried'; + String get plannerCanvas_issue_noDecoGas => '需要减压但未携带减压气体'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_results_noDeco => '无需减压'; @override String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; + return '永久删除“$name”?'; } @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_saved_deleteConfirmTitle => '删除计划?'; @override - String get plannerCanvas_saved_duplicate => 'Duplicate'; + String get plannerCanvas_saved_duplicate => '复制'; @override - String get plannerCanvas_saved_empty => 'No saved plans yet'; + String get plannerCanvas_saved_empty => '尚无已保存的计划'; @override - String get plannerCanvas_saved_title => 'Saved plans'; + String get plannerCanvas_saved_title => '已保存的计划'; @override String plannerCanvas_scrub_readout(String minutes, String depth) { @@ -9973,16 +9971,16 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get plannerCanvas_table_depth => 'Depth'; + String get plannerCanvas_table_depth => '深度'; @override - String get plannerCanvas_table_gas => 'Gas'; + String get plannerCanvas_table_gas => '气体'; @override String get plannerCanvas_table_runtime => 'RT'; @override - String get plannerCanvas_table_stop => 'Stop'; + String get plannerCanvas_table_stop => '停留'; @override String get planning_appBar_title => '计划'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 32925bec9..64548d3d6 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3235,6 +3235,23 @@ "onboarding_welcome_nameValidation": "Voer je naam in", "onboarding_welcome_subtitle": "Geavanceerd duiklogboek en analyse", "onboarding_welcome_title": "Welkom bij Submersion", + "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", + "plannerCanvas_issue_gasDensityHigh": "Gasdichtheid {value} g/L boven aanbevolen limiet", + "plannerCanvas_issue_hypoxic": "Hypoxisch gas op {depth} (ppO₂ {value} bar)", + "plannerCanvas_issue_noDecoGas": "Decompressie vereist maar geen decogas meegenomen", + "plannerCanvas_results_noDeco": "Geen decompressie vereist", + "plannerCanvas_saved_deleteConfirmBody": "'{name}' definitief verwijderen?", + "plannerCanvas_saved_deleteConfirmTitle": "Plan verwijderen?", + "plannerCanvas_saved_duplicate": "Dupliceren", + "plannerCanvas_saved_empty": "Nog geen opgeslagen plannen", + "plannerCanvas_saved_title": "Opgeslagen plannen", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "Diepte", + "plannerCanvas_table_gas": "Gas", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Stop", "planning_appBar_title": "Planning", "planning_card_decoCalculator_description": "Bereken no-decompressielimieten, benodigde decostops en CNS/OTU-blootstelling voor duikprofielen met meerdere niveaus.", "planning_card_decoCalculator_subtitle": "Plan duiken met decompressiestops", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index a2c064338..c8a92529d 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3235,6 +3235,23 @@ "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_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", + "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_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}\"?", + "plannerCanvas_saved_deleteConfirmTitle": "Excluir plano?", + "plannerCanvas_saved_duplicate": "Duplicar", + "plannerCanvas_saved_empty": "Ainda não há planos salvos", + "plannerCanvas_saved_title": "Planos salvos", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "Profundidade", + "plannerCanvas_table_gas": "Gás", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "Parada", "planning_appBar_title": "Planejamento", "planning_card_decoCalculator_description": "Calcule limites de nao descompressao, paradas deco necessarias e exposicao CNS/OTU para perfis de mergulho multinivel.", "planning_card_decoCalculator_subtitle": "Planeje mergulhos com paradas de descompressao", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 98ded83a9..597105df4 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3385,6 +3385,23 @@ "onboarding_welcome_nameValidation": "请输入您的名字", "onboarding_welcome_subtitle": "高级潜水日志与分析", "onboarding_welcome_title": "欢迎使用 Submersion", + "plannerCanvas_chip_cns": "CNS {value}%", + "plannerCanvas_chip_issues": "{count, plural, other{{count} 个问题}}", + "plannerCanvas_issue_gasDensityCritical": "气体密度 {value} g/L 超过硬性上限", + "plannerCanvas_issue_gasDensityHigh": "气体密度 {value} g/L 超过建议上限", + "plannerCanvas_issue_hypoxic": "{depth} 处为低氧气体(ppO₂ {value} bar)", + "plannerCanvas_issue_noDecoGas": "需要减压但未携带减压气体", + "plannerCanvas_results_noDeco": "无需减压", + "plannerCanvas_saved_deleteConfirmBody": "永久删除“{name}”?", + "plannerCanvas_saved_deleteConfirmTitle": "删除计划?", + "plannerCanvas_saved_duplicate": "复制", + "plannerCanvas_saved_empty": "尚无已保存的计划", + "plannerCanvas_saved_title": "已保存的计划", + "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_table_depth": "深度", + "plannerCanvas_table_gas": "气体", + "plannerCanvas_table_runtime": "RT", + "plannerCanvas_table_stop": "停留", "planning_appBar_title": "计划", "planning_card_decoCalculator_description": "计算免减压极限、所需减压停留以及多层潜水轮廓的中枢神经系统毒性/氧毒性单位暴露量。", "planning_card_decoCalculator_subtitle": "规划需要减压停留的潜水", From ee21373b26167b4c515124fa345ba4e2f1ac4be1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 16:35:29 -0400 Subject: [PATCH 8/8] test(planner): raise phase 3 canvas patch coverage; address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wide layout: hoist and dispose the results ScrollController (was created per build); wire the issues chip to scroll the pane to the issues section - Drop the hardcoded 'OC' badge — a real state-derived OC/CCR toggle lands with CCR support in a later phase - Add canvas page/chart interaction coverage (menus, dialogs, sheets, gas switches, scrub readout) --- .../presentation/pages/plan_canvas_page.dart | 32 ++++-- .../planner/plan_canvas_chart_test.dart | 46 ++++++++ .../planner/plan_canvas_page_test.dart | 100 ++++++++++++++++++ 3 files changed, 168 insertions(+), 10 deletions(-) diff --git a/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart index 3c09e4e6c..76d718e2e 100644 --- a/lib/features/planner/presentation/pages/plan_canvas_page.dart +++ b/lib/features/planner/presentation/pages/plan_canvas_page.dart @@ -30,6 +30,10 @@ class PlanCanvasPage extends ConsumerStatefulWidget { class _PlanCanvasPageState extends ConsumerState { final _sheetController = DraggableScrollableController(); + /// Owns the always-visible results pane on wide layouts so it is created + /// once (not per build) and disposed with the page. + final _wideResultsController = ScrollController(); + @override void initState() { super.initState(); @@ -47,6 +51,7 @@ class _PlanCanvasPageState extends ConsumerState { @override void dispose() { _sheetController.dispose(); + _wideResultsController.dispose(); super.dispose(); } @@ -57,16 +62,12 @@ class _PlanCanvasPageState extends ConsumerState { return Scaffold( appBar: AppBar( + // The mode badge (OC/CCR) lands with CCR support in a later phase, + // where it derives from plan state; there is only open circuit here, + // so a hardcoded badge would be noise. title: InkWell( onTap: () => _showRenameDialog(context), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: Text(planState.name)), - const SizedBox(width: 8), - const PlanChip(label: 'OC'), - ], - ), + child: Text(planState.name), ), actions: [ IconButton( @@ -200,6 +201,17 @@ class _PlanCanvasPageState extends ConsumerState { ); } + /// On wide layouts the results pane is always visible; scroll it to the + /// issues section (the last one) when the issues chip is tapped. + void _scrollWideToIssues() { + if (!_wideResultsController.hasClients) return; + _wideResultsController.animateTo( + _wideResultsController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + // --- Wide: editor column, chart + chips, always-visible results pane --- Widget _buildWide() { @@ -230,11 +242,11 @@ class _PlanCanvasPageState extends ConsumerState { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: PlanStatusChips(onIssuesTap: () {}), + child: PlanStatusChips(onIssuesTap: _scrollWideToIssues), ), SizedBox( height: 260, - child: PlanResultsSheet(controller: ScrollController()), + child: PlanResultsSheet(controller: _wideResultsController), ), ], ), diff --git a/test/features/planner/plan_canvas_chart_test.dart b/test/features/planner/plan_canvas_chart_test.dart index 0c8e29b3b..33872ca55 100644 --- a/test/features/planner/plan_canvas_chart_test.dart +++ b/test/features/planner/plan_canvas_chart_test.dart @@ -1,11 +1,13 @@ import 'package:fl_chart/fl_chart.dart'; 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/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/presentation/providers/plan_canvas_providers.dart'; import 'package:submersion/features/planner/presentation/widgets/plan_canvas_chart.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -88,5 +90,49 @@ void main() { expect(find.byType(LineChart), findsOneWidget); }); + + testWidgets('empty-state action opens the quick-plan dialog', ( + tester, + ) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.auto_awesome)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + }); + + testWidgets('gas-switch markers and the scrub readout render', ( + tester, + ) async { + await tester.pumpWidget(harness()); + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanCanvasChart)), + ); + // Deep air with an O2 deco gas gives a gas switch during the ascent. + final notifier = container.read(divePlanNotifierProvider.notifier); + notifier.addSimplePlan(maxDepth: 45, bottomTimeMinutes: 25); + notifier.addTank( + const DiveTank( + id: 'o2', + volume: 11.1, + startPressure: 207, + gasMix: GasMix(o2: 100), + role: TankRole.deco, + ), + ); + await tester.pumpAndSettle(); + + final chart = tester.widget(find.byType(LineChart)); + expect( + chart.data.extraLinesData.verticalLines, + isNotEmpty, + reason: 'a gas switch should draw a vertical marker line', + ); + + // Scrubbing renders the readout overlay. + container.read(scrubTimeProvider.notifier).state = 300; + await tester.pumpAndSettle(); + expect(find.textContaining('RT'), findsOneWidget); + }); }); } diff --git a/test/features/planner/plan_canvas_page_test.dart b/test/features/planner/plan_canvas_page_test.dart index 2919bd51c..99a420c8b 100644 --- a/test/features/planner/plan_canvas_page_test.dart +++ b/test/features/planner/plan_canvas_page_test.dart @@ -98,4 +98,104 @@ void main() { expect(container.read(divePlanNotifierProvider).isDirty, isFalse); }); + + Future openMenu(WidgetTester tester, String label) async { + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text(label).last); + await tester.pumpAndSettle(); + } + + testWidgets('quick-plan menu opens the simple-plan dialog', (tester) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + await openMenu(tester, 'Quick Plan'); + expect(find.byType(AlertDialog), findsOneWidget); + }); + + testWidgets('saved menu opens the saved-plans sheet', (tester) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + await openMenu(tester, 'Saved plans'); + expect(find.text('No saved plans yet'), findsOneWidget); + }); + + testWidgets('settings menu opens a settings sheet', (tester) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + await openMenu(tester, 'Plan Settings'); + expect(find.byType(PlanSettingsPanel), findsOneWidget); + }); + + testWidgets('reset menu shows a confirm dialog and clears the plan', ( + tester, + ) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanCanvasPage)), + ); + container + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 30, bottomTimeMinutes: 20); + await tester.pumpAndSettle(); + expect(container.read(divePlanNotifierProvider).segments, isNotEmpty); + + await openMenu(tester, 'Reset Plan'); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap(find.text('Reset').last); + await tester.pumpAndSettle(); + expect(container.read(divePlanNotifierProvider).segments, isEmpty); + }); + + testWidgets('convert menu surfaces a message', (tester) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + seed(tester); + await tester.pumpAndSettle(); + await openMenu(tester, 'Convert to Dive'); + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('tapping the title renames the plan', (tester) async { + await setSize(tester, const Size(420, 900)); + await tester.pumpWidget(harness()); + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanCanvasPage)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('New Dive Plan')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'Reef wall'); + await tester.tap(find.text('Save').last); + await tester.pumpAndSettle(); + + expect(container.read(divePlanNotifierProvider).name, 'Reef wall'); + }); + + testWidgets('wide issues chip scrolls the results pane without leaking', ( + tester, + ) async { + await setSize(tester, const Size(1400, 900)); + await tester.pumpWidget(harness()); + final container = ProviderScope.containerOf( + tester.element(find.byType(PlanCanvasPage)), + ); + // A deep air plan trips a critical gas-density issue, so an issues chip + // renders and the tap has a target. + container + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 50, bottomTimeMinutes: 25); + await tester.pumpAndSettle(); + + final issuesChip = find.textContaining('issue'); + expect(issuesChip, findsWidgets); + await tester.tap(issuesChip.first); + await tester.pumpAndSettle(); + // No exception = the hoisted controller is wired and disposed by the page. + }); }