From 09628b20ba6a8ecd28cf578f58bb33109376b830 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:12:41 -0400 Subject: [PATCH 1/9] docs: add dive planner phase 7 (outputs + hub) plan --- .../2026-07-05-dive-planner-phase7-outputs.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-05-dive-planner-phase7-outputs.md diff --git a/docs/superpowers/plans/2026-07-05-dive-planner-phase7-outputs.md b/docs/superpowers/plans/2026-07-05-dive-planner-phase7-outputs.md new file mode 100644 index 000000000..17448711d --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-dive-planner-phase7-outputs.md @@ -0,0 +1,115 @@ +# Dive Planner Phase 7 — Outputs + Hub Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans +> to implement this plan task-by-task. + +**Goal:** Ship the plan outputs (slate PDF, .subplan share file, range tables, +multi-plan compare) and reorganize the planning hub around the planner. + +**Architecture:** All outputs are pure consumers of `PlanEngine`/`PlanOutcome` +— no engine changes. The slate PDF reuses the existing +`lib/core/services/export/` infrastructure (`pw.Document`, `sharePdfBytes`). +Range tables are a small domain service (grid of engine runs). The .subplan +file is versioned JSON over the existing `domain.DivePlan` aggregate. The hub +page gets saved plans + New plan first, tools below. + +**Tech Stack:** `pdf`/`printing`/`share_plus`/`file_picker` (all already in +pubspec), Riverpod, existing PlanEngine. + +## Global Constraints + +- Deco math only through the shared engine (`PlanEngine`/`BuhlmannGf`). +- Units respect diver settings (`UnitFormatter`) — including inside the PDF. +- New strings in `app_en.arb` + 10 locales, alphabetical, `flutter gen-l10n`. +- `dart format .` and whole-project `flutter analyze` clean; commit per task. + +--- + +### Task 1: Range table service + +**Files:** +- Create: `lib/features/planner/domain/services/range_table_service.dart` +- Test: `test/features/planner/range_table_service_test.dart` + +`RangeTableService.compute(domain.DivePlan plan, {depthDeltas: [-6,-3,0,3,6], timeDeltas: [-10,-5,0,5,10]})` +→ `RangeTable` (rows = depth variants, cols = time variants, each cell +`RangeCell(runtimeSeconds, ttsSeconds, totalDecoSeconds, isDiveable)`), built +by re-running the engine on deviated plans via the existing +`ContingencyService`-style segment adjustment (deepen bottom segments / +extend last bottom segment). Skip variants that go ≤ 0 m or ≤ 0 min. Test: +base cell matches the plan's own outcome; deeper/longer cells have ≥ TTS. + +### Task 2: Range table UI section + +**Files:** +- Create: `lib/features/planner/presentation/widgets/range_table_section.dart` +- Modify: `plan_results_sheet.dart` (append section), `plan_canvas_providers.dart` (provider) +- Test: `test/features/planner/range_table_section_test.dart` + +`planRangeTableProvider` (Provider, watches state + engine config; empty when +plan has no deco-relevant segments). Compact grid: depth rows × time columns, +cell shows TTS minutes (tinted when not diveable). Widget test via the +existing results-sheet harness pattern. + +### Task 3: Slate PDF export + +**Files:** +- Create: `lib/features/planner/data/services/plan_slate_pdf_service.dart` +- Modify: `plan_canvas_page.dart` (menu: "Export slate (PDF)") +- Test: `test/features/planner/plan_slate_pdf_test.dart` + +`PlanSlatePdfService.buildSlate({plan, outcome, deviations, lostGas, rangeTable, bailout, units, labels})` +→ `Future>` (pw.Document): header (name, date, mode, GF), main +runtime table, gas plan (per-tank usage, turn pressure, min gas), deviation +tables, lost-gas tables, bailout summary (CCR), range table. High-contrast +monochrome for print. Menu action shares via existing `sharePdfBytes`. Test: +bytes non-empty + parseable header (`%PDF`), and builder covers OC + CCR +plans without throwing. + +### Task 4: .subplan share + import + +**Files:** +- Create: `lib/features/planner/data/services/plan_file_codec.dart` +- Modify: `saved_plans_sheet.dart` (per-plan Share action, Import action in header), `plan_canvas_page.dart` (menu Share plan) +- Test: `test/features/planner/plan_file_codec_test.dart` + +`planToSubplanJson(domain.DivePlan) → String` (envelope +`{format: "submersion-plan", version: 1, plan: {...}}`) and +`subplanFromJson(String) → domain.DivePlan` (fresh ids on import to avoid +collisions; throws FormatException on wrong format/newer version). Share via +`saveAndShareFile`; import via `file_picker` → repository.savePlan → open. +Test: round-trip equality on the engine-relevant fields (segments, tanks, +GF, mode, setpoints, contingency config); version guard throws. + +### Task 5: Multi-plan compare + +**Files:** +- Create: `lib/features/planner/presentation/pages/plan_compare_page.dart` +- Modify: `saved_plans_sheet.dart` (multi-select → Compare), router (`/planning/dive-planner/compare?ids=`) +- Test: `test/features/planner/plan_compare_test.dart` + +Pick 2-3 saved plans → overlaid profiles (fl_chart, one color per plan, reuse +`buildCanvasSeries`) + diff table (runtime, TTS, deco time, max depth, CNS, +per-tank gas). Provider loads plans by id and computes outcomes. Test: diff +rows present for 2 plans; profiles map to distinct line bars. + +### Task 6: Hub redesign + deco calculator environment + +**Files:** +- Modify: `lib/features/planning/presentation/pages/planning_page.dart` +- Modify: `lib/features/deco_calculator/presentation/providers/deco_calculator_providers.dart` (+ page inputs) +- Test: `test/features/planning/planning_page_test.dart` (update/create) + +Hub: "New plan" CTA + recent saved plans (top 3 via +`divePlanSummariesProvider`, tap → open, "All plans" → saved-plans sheet) +above the tools list; tools keep existing tiles (restyled section header). +Deco calculator: altitude + water-type inputs feeding +`DiveEnvironment.forConditions` into `BuhlmannAlgorithm(environment:)` so it +agrees with the planner at altitude. + +### Task 7: l10n + verification sweep + PR + +Translate new keys into all locales, `flutter gen-l10n`, `dart format .`, +whole-project `flutter analyze`, run `test/features/planner/` + +`test/features/planning/` + `test/features/deco_calculator/`, push +`worktree-dive-planner-phase7-outputs`, PR stacked on the Phase 6 branch. From 06de28e23d09b4ff6ae571996f6de0bf3a793a5c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:15:01 -0400 Subject: [PATCH 2/9] feat(planner): range table service over deviated engine runs --- .../domain/services/contingency_service.dart | 59 +++++--- .../domain/services/range_table_service.dart | 128 ++++++++++++++++++ .../planner/range_table_service_test.dart | 106 +++++++++++++++ 3 files changed, 272 insertions(+), 21 deletions(-) create mode 100644 lib/features/planner/domain/services/range_table_service.dart create mode 100644 test/features/planner/range_table_service_test.dart diff --git a/lib/features/planner/domain/services/contingency_service.dart b/lib/features/planner/domain/services/contingency_service.dart index ed0a494ed..ef63eac94 100644 --- a/lib/features/planner/domain/services/contingency_service.dart +++ b/lib/features/planner/domain/services/contingency_service.dart @@ -79,37 +79,54 @@ class ContingencyService { return results; } - /// Depths equal to the plan's max depth grow by the deviation delta — - /// the bottom deepens and the descent that feeds it follows. - domain.DivePlan _deepened(domain.DivePlan plan) { + domain.DivePlan _deepened(domain.DivePlan plan) => + deviatePlan(plan, depthDelta: plan.deviationDepthDelta); + + domain.DivePlan _lengthened(domain.DivePlan plan) => + deviatePlan(plan, timeDeltaMinutes: plan.deviationTimeMinutes); +} + +/// A [plan] variant deviated by [depthDelta] meters and/or +/// [timeDeltaMinutes] minutes (either may be negative). +/// +/// Depths equal to the plan's max depth shift by the delta — the bottom +/// moves and the descent that feeds it follows. Bottom segments grow (or +/// shrink) by the time delta. Shared by the contingency trio and the range +/// tables so every "what if" variant deviates the same way. +domain.DivePlan deviatePlan( + domain.DivePlan plan, { + double depthDelta = 0, + int timeDeltaMinutes = 0, +}) { + var segments = plan.segments; + + if (depthDelta != 0) { final maxDepth = plan.maxDepth; - final delta = plan.deviationDepthDelta; - PlanSegment deepen(PlanSegment segment) { + PlanSegment shift(PlanSegment segment) { var changed = segment; if ((segment.startDepth - maxDepth).abs() < 0.01) { - changed = changed.copyWith(startDepth: segment.startDepth + delta); + changed = changed.copyWith(startDepth: segment.startDepth + depthDelta); } if ((segment.endDepth - maxDepth).abs() < 0.01) { - changed = changed.copyWith(endDepth: segment.endDepth + delta); + changed = changed.copyWith(endDepth: segment.endDepth + depthDelta); } return changed; } - return plan.copyWith(segments: plan.segments.map(deepen).toList()); + segments = segments.map(shift).toList(); } - /// Bottom segments grow by the deviation minutes. - domain.DivePlan _lengthened(domain.DivePlan plan) { - final extraSeconds = plan.deviationTimeMinutes * 60; - return plan.copyWith( - segments: [ - for (final segment in plan.segments) - segment.type == SegmentType.bottom - ? segment.copyWith( - durationSeconds: segment.durationSeconds + extraSeconds, - ) - : segment, - ], - ); + if (timeDeltaMinutes != 0) { + final extraSeconds = timeDeltaMinutes * 60; + segments = [ + for (final segment in segments) + segment.type == SegmentType.bottom + ? segment.copyWith( + durationSeconds: segment.durationSeconds + extraSeconds, + ) + : segment, + ]; } + + return plan.copyWith(segments: segments); } diff --git a/lib/features/planner/domain/services/range_table_service.dart b/lib/features/planner/domain/services/range_table_service.dart new file mode 100644 index 000000000..fd0945c73 --- /dev/null +++ b/lib/features/planner/domain/services/range_table_service.dart @@ -0,0 +1,128 @@ +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; +import 'package:submersion/features/planner/domain/services/contingency_service.dart'; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; + +/// One depth/time variant of the base plan in a range table. +class RangeCell { + final double depthDelta; + final int timeDelta; + final PlanOutcome outcome; + + const RangeCell({ + required this.depthDelta, + required this.timeDelta, + required this.outcome, + }); + + bool get isBase => depthDelta == 0 && timeDelta == 0; +} + +/// The classic slate matrix: depth variants (rows) x time variants +/// (columns). A null cell means the variant was not computable (it would +/// have taken a segment to zero depth or zero duration). +class RangeTable { + final List depthDeltas; + final List timeDeltas; + + /// Row-major: `cells[depthIndex][timeIndex]`. + final List> cells; + + const RangeTable({ + required this.depthDeltas, + required this.timeDeltas, + required this.cells, + }); + + bool get isEmpty => cells.every((row) => row.every((c) => c == null)); + + RangeCell? get baseCell { + for (final row in cells) { + for (final cell in row) { + if (cell != null && cell.isBase) return cell; + } + } + return null; + } +} + +/// Builds range tables by re-running the PlanEngine on deviated plans +/// (same deviation semantics as the contingency trio via [deviatePlan]). +class RangeTableService { + final PlanEngineConfig config; + + const RangeTableService({this.config = const PlanEngineConfig()}); + + static const defaultDepthDeltas = [-6.0, -3.0, 0.0, 3.0, 6.0]; + static const defaultTimeDeltas = [-10, -5, 0, 5, 10]; + + RangeTable compute( + domain.DivePlan plan, { + List depthDeltas = defaultDepthDeltas, + List timeDeltas = defaultTimeDeltas, + }) { + final engine = PlanEngine(config: config); + final maxDepth = plan.maxDepth; + final minBottomMinutes = _shortestBottomMinutes(plan); + + final cells = >[ + for (final depthDelta in depthDeltas) + [ + for (final timeDelta in timeDeltas) + _cell( + engine, + plan, + depthDelta, + timeDelta, + maxDepth: maxDepth, + minBottomMinutes: minBottomMinutes, + ), + ], + ]; + + return RangeTable( + depthDeltas: depthDeltas, + timeDeltas: timeDeltas, + cells: cells, + ); + } + + RangeCell? _cell( + PlanEngine engine, + domain.DivePlan plan, + double depthDelta, + int timeDelta, { + required double maxDepth, + required int? minBottomMinutes, + }) { + if (plan.segments.isEmpty) return null; + // A variant must keep the bottom under water and its duration positive. + if (maxDepth + depthDelta <= 0) return null; + if (minBottomMinutes != null && minBottomMinutes + timeDelta <= 0) { + return null; + } + + final variant = deviatePlan( + plan, + depthDelta: depthDelta, + timeDeltaMinutes: timeDelta, + ); + return RangeCell( + depthDelta: depthDelta, + timeDelta: timeDelta, + outcome: engine.compute(variant), + ); + } + + int? _shortestBottomMinutes(domain.DivePlan plan) { + int? shortest; + for (final segment in plan.segments) { + if (segment.type != SegmentType.bottom) continue; + final minutes = segment.durationSeconds ~/ 60; + if (shortest == null || minutes < shortest) shortest = minutes; + } + return shortest; + } +} diff --git a/test/features/planner/range_table_service_test.dart b/test/features/planner/range_table_service_test.dart new file mode 100644 index 000000000..d0c1a7d5a --- /dev/null +++ b/test/features/planner/range_table_service_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; +import 'package:submersion/features/planner/domain/services/range_table_service.dart'; + +const _air = GasMix(o2: 21); +const _airTank = DiveTank( + id: 'tank-1', + volume: 11.1, + startPressure: 207.0, + gasMix: _air, +); + +domain.DivePlan _plan({double depth = 40.0, int minutes = 20}) { + return domain.DivePlan( + id: 'plan-1', + name: 'Range test', + gfLow: 40, + gfHigh: 80, + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: depth, + tankId: 'tank-1', + gasMix: _air, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: depth, + durationMinutes: minutes, + tankId: 'tank-1', + gasMix: _air, + order: 1, + ), + ], + tanks: const [_airTank], + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +void main() { + group('RangeTableService', () { + test('base cell matches the plan computed directly', () { + const service = RangeTableService(); + final table = service.compute(_plan()); + final base = table.baseCell; + expect(base, isNotNull); + + final direct = const PlanEngine().compute(_plan()); + expect(base!.outcome.ttsAtBottom, direct.ttsAtBottom); + expect(base.outcome.runtimeSeconds, direct.runtimeSeconds); + }); + + test('deeper and longer variants carry at least the base TTS', () { + const service = RangeTableService(); + final table = service.compute(_plan()); + final base = table.baseCell!; + + for (final row in table.cells) { + for (final cell in row) { + if (cell == null) continue; + if (cell.depthDelta >= 0 && cell.timeDelta >= 0 && !cell.isBase) { + expect( + cell.outcome.ttsAtBottom, + greaterThanOrEqualTo(base.outcome.ttsAtBottom), + reason: '+${cell.depthDelta} m / +${cell.timeDelta} min', + ); + } + } + } + }); + + test('variants that would zero out the bottom are skipped', () { + const service = RangeTableService(); + // 8-minute bottom: the -10 min column must be null, -5 min computes. + final table = service.compute(_plan(minutes: 8)); + final minusTenIndex = table.timeDeltas.indexOf(-10); + final minusFiveIndex = table.timeDeltas.indexOf(-5); + for (final row in table.cells) { + expect(row[minusTenIndex], isNull); + } + final baseRow = table.cells[table.depthDeltas.indexOf(0.0)]; + expect(baseRow[minusFiveIndex], isNotNull); + }); + + test('empty plan yields an empty table', () { + const service = RangeTableService(); + final table = service.compute( + domain.DivePlan( + id: 'p', + name: 'empty', + gfLow: 40, + gfHigh: 80, + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ), + ); + expect(table.isEmpty, isTrue); + }); + }); +} From 63c6018433863ad4de092239a54cc6f6641dd0f9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:18:06 -0400 Subject: [PATCH 3/9] feat(planner): range table section in the results sheet --- .../providers/plan_canvas_providers.dart | 13 +++ .../widgets/plan_results_sheet.dart | 6 ++ .../widgets/range_table_section.dart | 100 ++++++++++++++++++ lib/l10n/arb/app_en.arb | 15 +-- lib/l10n/arb/app_localizations.dart | 30 ++++-- lib/l10n/arb/app_localizations_ar.dart | 20 +++- lib/l10n/arb/app_localizations_de.dart | 20 +++- lib/l10n/arb/app_localizations_en.dart | 20 +++- lib/l10n/arb/app_localizations_es.dart | 20 +++- lib/l10n/arb/app_localizations_fr.dart | 20 +++- lib/l10n/arb/app_localizations_he.dart | 20 +++- lib/l10n/arb/app_localizations_hu.dart | 20 +++- lib/l10n/arb/app_localizations_it.dart | 20 +++- lib/l10n/arb/app_localizations_nl.dart | 20 +++- lib/l10n/arb/app_localizations_pt.dart | 20 +++- lib/l10n/arb/app_localizations_zh.dart | 20 +++- .../planner/range_table_section_test.dart | 53 ++++++++++ 17 files changed, 370 insertions(+), 67 deletions(-) create mode 100644 lib/features/planner/presentation/widgets/range_table_section.dart create mode 100644 test/features/planner/range_table_section_test.dart diff --git a/lib/features/planner/presentation/providers/plan_canvas_providers.dart b/lib/features/planner/presentation/providers/plan_canvas_providers.dart index 487a9f1fe..95ea45111 100644 --- a/lib/features/planner/presentation/providers/plan_canvas_providers.dart +++ b/lib/features/planner/presentation/providers/plan_canvas_providers.dart @@ -8,6 +8,7 @@ import 'package:submersion/features/planner/domain/services/bailout_solver.dart' import 'package:submersion/features/planner/domain/services/contingency_service.dart'; import 'package:submersion/features/planner/domain/services/dive_plan_state_mapper.dart'; import 'package:submersion/features/planner/domain/services/plan_engine.dart'; +import 'package:submersion/features/planner/domain/services/range_table_service.dart'; import 'package:submersion/features/planner/domain/services/tissue_seed.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; @@ -247,6 +248,18 @@ final planLostGasProvider = Provider>((ref) { return service.lostGas(divePlanFromState(state)); }); +/// The classic slate range table (depth x time variants) for the current +/// plan; null when there is nothing to vary. +final planRangeTableProvider = Provider((ref) { + final state = ref.watch(divePlanNotifierProvider); + if (state.segments.isEmpty) return null; + final service = RangeTableService( + config: ref.watch(planEngineConfigProvider), + ); + final table = service.compute(divePlanFromState(state)); + return table.isEmpty ? null : table; +}); + /// Which deviation is ghosted on the chart ('deeper'|'longer'|'both'; null = /// none). final selectedDeviationProvider = StateProvider((_) => null); diff --git a/lib/features/planner/presentation/widgets/plan_results_sheet.dart b/lib/features/planner/presentation/widgets/plan_results_sheet.dart index 4668238af..3b9eb5a43 100644 --- a/lib/features/planner/presentation/widgets/plan_results_sheet.dart +++ b/lib/features/planner/presentation/widgets/plan_results_sheet.dart @@ -8,6 +8,7 @@ import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; import 'package:submersion/features/planner/domain/services/bailout_solver.dart'; import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; import 'package:submersion/features/planner/presentation/widgets/plan_status_chips.dart'; +import 'package:submersion/features/planner/presentation/widgets/range_table_section.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -125,6 +126,11 @@ class PlanResultsSheet extends ConsumerWidget { _BailoutSection(outcome: bailout, units: units), ], ...?_contingencySections(context, ref, units), + if (ref.watch(planRangeTableProvider) != null) ...[ + const SizedBox(height: 20), + _SectionHeader(context.l10n.plannerCanvas_range_title), + const RangeTableSection(), + ], const SizedBox(height: 20), _SectionHeader(context.l10n.divePlanner_label_warnings), if (outcome.issues.isEmpty) diff --git a/lib/features/planner/presentation/widgets/range_table_section.dart b/lib/features/planner/presentation/widgets/range_table_section.dart new file mode 100644 index 000000000..d93280d48 --- /dev/null +++ b/lib/features/planner/presentation/widgets/range_table_section.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/planner/domain/services/range_table_service.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 classic slate range grid: depth variants (rows) x time variants +/// (columns), each cell the variant's TTS in minutes. Renders nothing when +/// the plan has nothing to vary. +class RangeTableSection extends ConsumerWidget { + const RangeTableSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final table = ref.watch(planRangeTableProvider); + if (table == null) return const SizedBox.shrink(); + final units = UnitFormatter(ref.watch(settingsProvider)); + final theme = Theme.of(context); + + String depthLabel(double delta) { + if (delta == 0) return context.l10n.plannerCanvas_range_base; + final sign = delta > 0 ? '+' : '−'; + return '$sign${units.formatDepth(delta.abs(), decimals: 0)}'; + } + + String timeLabel(int delta) { + if (delta == 0) return context.l10n.plannerCanvas_range_base; + final sign = delta > 0 ? '+' : '−'; + return '$sign${delta.abs()}′'; + } + + Widget cell( + String text, { + bool header = false, + bool emphasized = false, + Color? tint, + }) { + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text( + text, + textAlign: TextAlign.center, + style: + (header + ? theme.textTheme.labelSmall + : theme.textTheme.bodySmall) + ?.copyWith( + color: + tint ?? (header ? theme.colorScheme.outline : null), + fontWeight: emphasized || header ? FontWeight.w600 : null, + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + cell('', header: true), + for (final timeDelta in table.timeDeltas) + cell(timeLabel(timeDelta), header: true), + ], + ), + for (var d = 0; d < table.depthDeltas.length; d++) + Row( + children: [ + cell(depthLabel(table.depthDeltas[d]), header: true), + for (final rangeCell in table.cells[d]) + if (rangeCell == null) + cell('--') + else + cell( + '${(rangeCell.outcome.ttsAtBottom / 60).ceil()}′', + emphasized: rangeCell.isBase, + tint: rangeCell.outcome.isDiveable + ? null + : theme.colorScheme.error, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + context.l10n.plannerCanvas_range_legend, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ), + ], + ); + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7f755f7ab..fa8cacb31 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5739,7 +5739,16 @@ }, "plannerCanvas_issue_noBailout": "CCR decompression plan carries no bailout gas", "plannerCanvas_issue_noDecoGas": "Decompression required but no deco gas carried", + "plannerCanvas_range_base": "Base", + "plannerCanvas_range_legend": "Cells show time to surface; red = not diveable as planned", + "plannerCanvas_range_title": "Range table", "plannerCanvas_results_noDeco": "No decompression required", + "plannerCanvas_sac_useLogged": "Use logged average ({sac})", + "@plannerCanvas_sac_useLogged": { + "placeholders": { + "sac": { "type": "String" } + } + }, "plannerCanvas_saved_deleteConfirmBody": "Permanently delete \"{name}\"?", "@plannerCanvas_saved_deleteConfirmBody": { "placeholders": { @@ -5747,12 +5756,6 @@ } }, "plannerCanvas_saved_deleteConfirmTitle": "Delete plan?", - "plannerCanvas_sac_useLogged": "Use logged average ({sac})", - "@plannerCanvas_sac_useLogged": { - "placeholders": { - "sac": { "type": "String" } - } - }, "plannerCanvas_saved_duplicate": "Duplicate", "plannerCanvas_saved_empty": "No saved plans yet", "plannerCanvas_saved_title": "Saved plans", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 8fb86edd5..08f129adb 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17846,12 +17846,36 @@ abstract class AppLocalizations { /// **'Decompression required but no deco gas carried'** String get plannerCanvas_issue_noDecoGas; + /// No description provided for @plannerCanvas_range_base. + /// + /// In en, this message translates to: + /// **'Base'** + String get plannerCanvas_range_base; + + /// No description provided for @plannerCanvas_range_legend. + /// + /// In en, this message translates to: + /// **'Cells show time to surface; red = not diveable as planned'** + String get plannerCanvas_range_legend; + + /// No description provided for @plannerCanvas_range_title. + /// + /// In en, this message translates to: + /// **'Range table'** + String get plannerCanvas_range_title; + /// 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_sac_useLogged. + /// + /// In en, this message translates to: + /// **'Use logged average ({sac})'** + String plannerCanvas_sac_useLogged(String sac); + /// No description provided for @plannerCanvas_saved_deleteConfirmBody. /// /// In en, this message translates to: @@ -17864,12 +17888,6 @@ abstract class AppLocalizations { /// **'Delete plan?'** String get plannerCanvas_saved_deleteConfirmTitle; - /// No description provided for @plannerCanvas_sac_useLogged. - /// - /// In en, this message translates to: - /// **'Use logged average ({sac})'** - String plannerCanvas_sac_useLogged(String sac); - /// No description provided for @plannerCanvas_saved_duplicate. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 4bbffb3f4..0d9861aab 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10298,21 +10298,31 @@ class AppLocalizationsAr extends AppLocalizations { 'يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف'; @override - String get plannerCanvas_results_noDeco => 'لا يلزم تخفيف الضغط'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'حذف \"$name\" نهائيًا؟'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'حذف الخطة؟'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'لا يلزم تخفيف الضغط'; @override String plannerCanvas_sac_useLogged(String sac) { return 'استخدام المتوسط المسجل ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'حذف \"$name\" نهائيًا؟'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'حذف الخطة؟'; + @override String get plannerCanvas_saved_duplicate => 'تكرار'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 9a5a7cc8e..b75f104fc 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10492,21 +10492,31 @@ class AppLocalizationsDe extends AppLocalizations { 'Dekompression erforderlich, aber kein Deko-Gas mitgeführt'; @override - String get plannerCanvas_results_noDeco => 'Keine Dekompression erforderlich'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return '„$name“ endgültig löschen?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Plan löschen?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'Keine Dekompression erforderlich'; @override String plannerCanvas_sac_useLogged(String sac) { return 'Geloggten Durchschnitt verwenden ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return '„$name“ endgültig löschen?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Plan löschen?'; + @override String get plannerCanvas_saved_duplicate => 'Duplizieren'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 11ac1e64d..a79a9c660 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10331,21 +10331,31 @@ class AppLocalizationsEn extends AppLocalizations { 'Decompression required but no deco gas carried'; @override - String get plannerCanvas_results_noDeco => 'No decompression required'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Permanently delete \"$name\"?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Delete plan?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'No decompression required'; @override String plannerCanvas_sac_useLogged(String sac) { return 'Use logged average ($sac)'; } + @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'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 4ef2bc2aa..2edfd89db 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10483,21 +10483,31 @@ class AppLocalizationsEs extends AppLocalizations { 'Se requiere descompresión pero no se lleva gas de deco'; @override - String get plannerCanvas_results_noDeco => 'No se requiere descompresión'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return '¿Eliminar permanentemente «$name»?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => '¿Eliminar plan?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'No se requiere descompresión'; @override String plannerCanvas_sac_useLogged(String sac) { return 'Usar promedio registrado ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return '¿Eliminar permanentemente «$name»?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => '¿Eliminar plan?'; + @override String get plannerCanvas_saved_duplicate => 'Duplicar'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 4e8b15a56..261d11313 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10533,21 +10533,31 @@ class AppLocalizationsFr extends AppLocalizations { 'Décompression requise mais aucun gaz de déco emporté'; @override - String get plannerCanvas_results_noDeco => 'Aucune décompression requise'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Supprimer définitivement « $name » ?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Supprimer le plan ?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'Aucune décompression requise'; @override String plannerCanvas_sac_useLogged(String sac) { return 'Utiliser la moyenne enregistrée ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Supprimer définitivement « $name » ?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Supprimer le plan ?'; + @override String get plannerCanvas_saved_duplicate => 'Dupliquer'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 4f6ef0ec7..803603370 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10231,21 +10231,31 @@ class AppLocalizationsHe extends AppLocalizations { 'נדרשת דקומפרסיה אך לא נלקח גז דקו'; @override - String get plannerCanvas_results_noDeco => 'לא נדרשת דקומפרסיה'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'למחוק לצמיתות את \"$name\"?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'למחוק את התוכנית?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'לא נדרשת דקומפרסיה'; @override String plannerCanvas_sac_useLogged(String sac) { return 'השתמש בממוצע המתועד ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'למחוק לצמיתות את \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'למחוק את התוכנית?'; + @override String get plannerCanvas_saved_duplicate => 'שכפול'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 4e4fda62a..57112c983 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10468,21 +10468,31 @@ class AppLocalizationsHu extends AppLocalizations { 'Dekompresszió szükséges, de nincs deco gáz'; @override - String get plannerCanvas_results_noDeco => 'Nincs szükség dekompresszióra'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Véglegesen törli a(z) „$name” tervet?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Törli a tervet?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'Nincs szükség dekompresszióra'; @override String plannerCanvas_sac_useLogged(String sac) { return 'Naplózott átlag használata ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Véglegesen törli a(z) „$name” tervet?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Törli a tervet?'; + @override String get plannerCanvas_saved_duplicate => 'Duplikálás'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 187d94bcd..3e121e9c5 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10496,10 +10496,25 @@ class AppLocalizationsIt extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => 'Decompressione necessaria ma nessun gas deco a bordo'; + @override + String get plannerCanvas_range_base => 'Base'; + + @override + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; + + @override + String get plannerCanvas_range_title => 'Range table'; + @override String get plannerCanvas_results_noDeco => 'Nessuna decompressione necessaria'; + @override + String plannerCanvas_sac_useLogged(String sac) { + return 'Usa media registrata ($sac)'; + } + @override String plannerCanvas_saved_deleteConfirmBody(String name) { return 'Eliminare definitivamente «$name»?'; @@ -10508,11 +10523,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get plannerCanvas_saved_deleteConfirmTitle => 'Eliminare il piano?'; - @override - String plannerCanvas_sac_useLogged(String sac) { - return 'Usa media registrata ($sac)'; - } - @override String get plannerCanvas_saved_duplicate => 'Duplica'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 938e4dc8c..c7a690b18 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10419,21 +10419,31 @@ class AppLocalizationsNl extends AppLocalizations { 'Decompressie vereist maar geen decogas meegenomen'; @override - String get plannerCanvas_results_noDeco => 'Geen decompressie vereist'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return '\'$name\' definitief verwijderen?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Plan verwijderen?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'Geen decompressie vereist'; @override String plannerCanvas_sac_useLogged(String sac) { return 'Gebruik gelogd gemiddelde ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return '\'$name\' definitief verwijderen?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Plan verwijderen?'; + @override String get plannerCanvas_saved_duplicate => 'Dupliceren'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 62ca6103d..8ac7d1570 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10504,21 +10504,31 @@ class AppLocalizationsPt extends AppLocalizations { 'Descompressão necessária mas sem gás de deco a bordo'; @override - String get plannerCanvas_results_noDeco => 'Nenhuma descompressão necessária'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Excluir permanentemente \"$name\"?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Excluir plano?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => 'Nenhuma descompressão necessária'; @override String plannerCanvas_sac_useLogged(String sac) { return 'Usar média registrada ($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return 'Excluir permanentemente \"$name\"?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => 'Excluir plano?'; + @override String get plannerCanvas_saved_duplicate => 'Duplicar'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 4e30e3780..ed2b86feb 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -10042,21 +10042,31 @@ class AppLocalizationsZh extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => '需要减压但未携带减压气体'; @override - String get plannerCanvas_results_noDeco => '无需减压'; + String get plannerCanvas_range_base => 'Base'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return '永久删除“$name”?'; - } + String get plannerCanvas_range_legend => + 'Cells show time to surface; red = not diveable as planned'; @override - String get plannerCanvas_saved_deleteConfirmTitle => '删除计划?'; + String get plannerCanvas_range_title => 'Range table'; + + @override + String get plannerCanvas_results_noDeco => '无需减压'; @override String plannerCanvas_sac_useLogged(String sac) { return '使用记录的平均值($sac)'; } + @override + String plannerCanvas_saved_deleteConfirmBody(String name) { + return '永久删除“$name”?'; + } + + @override + String get plannerCanvas_saved_deleteConfirmTitle => '删除计划?'; + @override String get plannerCanvas_saved_duplicate => '复制'; diff --git a/test/features/planner/range_table_section_test.dart b/test/features/planner/range_table_section_test.dart new file mode 100644 index 000000000..c463041a4 --- /dev/null +++ b/test/features/planner/range_table_section_test.dart @@ -0,0 +1,53 @@ +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/range_table_section.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() { + testWidgets('range section renders the depth x time grid', (tester) async { + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: const SingleChildScrollView(child: RangeTableSection()), + ), + ); + // Hidden with no plan. + expect(find.text('Base'), findsNothing); + + final container = ProviderScope.containerOf( + tester.element(find.byType(RangeTableSection)), + ); + container + .read(divePlanNotifierProvider.notifier) + .addSimplePlan(maxDepth: 40, bottomTimeMinutes: 20); + await tester.pumpAndSettle(); + + // Row and column headers share the "Base" label (2 axes). + expect(find.text('Base'), findsNWidgets(2)); + // Default deltas produce +/-3 and +/-6 depth rows and +/-5/10 columns. + expect(find.text('+3m'), findsOneWidget); + expect(find.text('−6m'), findsOneWidget); + expect(find.text('+10′'), findsOneWidget); + // Cells carry TTS minute values. + expect(find.textContaining('′'), findsWidgets); + }); +} From 9f68f9a6862df15c7091eb30d997fa139e3e91cf Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:22:58 -0400 Subject: [PATCH 4/9] feat(planner): printable dive slate PDF export --- .../data/services/plan_slate_pdf_service.dart | 339 ++++++++++++++++++ .../presentation/pages/plan_canvas_page.dart | 52 +++ .../widgets/range_table_section.dart | 1 - lib/l10n/arb/app_en.arb | 3 + lib/l10n/arb/app_localizations.dart | 18 + lib/l10n/arb/app_localizations_ar.dart | 9 + lib/l10n/arb/app_localizations_de.dart | 9 + lib/l10n/arb/app_localizations_en.dart | 9 + lib/l10n/arb/app_localizations_es.dart | 9 + lib/l10n/arb/app_localizations_fr.dart | 9 + lib/l10n/arb/app_localizations_he.dart | 9 + lib/l10n/arb/app_localizations_hu.dart | 9 + lib/l10n/arb/app_localizations_it.dart | 9 + lib/l10n/arb/app_localizations_nl.dart | 9 + lib/l10n/arb/app_localizations_pt.dart | 9 + lib/l10n/arb/app_localizations_zh.dart | 9 + .../features/planner/plan_slate_pdf_test.dart | 107 ++++++ 17 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 lib/features/planner/data/services/plan_slate_pdf_service.dart create mode 100644 test/features/planner/plan_slate_pdf_test.dart diff --git a/lib/features/planner/data/services/plan_slate_pdf_service.dart b/lib/features/planner/data/services/plan_slate_pdf_service.dart new file mode 100644 index 000000000..b288666b7 --- /dev/null +++ b/lib/features/planner/data/services/plan_slate_pdf_service.dart @@ -0,0 +1,339 @@ +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +import 'package:submersion/core/services/pdf_templates/pdf_fonts.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; +import 'package:submersion/features/planner/domain/services/bailout_solver.dart'; +import 'package:submersion/features/planner/domain/services/contingency_service.dart'; +import 'package:submersion/features/planner/domain/services/range_table_service.dart'; + +/// Localized strings the slate needs (assembled from context.l10n at the +/// call site so the service stays context-free). +class PlanSlateLabels { + final String runtimeTable; + final String gasPlan; + final String contingencies; + final String Function(String gas) lostGasLabel; + final String rangeTable; + final String bailout; + final String stop; + final String depth; + final String runtime; + final String gas; + final String turnAt; + final String minGas; + final String base; + + const PlanSlateLabels({ + required this.runtimeTable, + required this.gasPlan, + required this.contingencies, + required this.lostGasLabel, + required this.rangeTable, + required this.bailout, + required this.stop, + required this.depth, + required this.runtime, + required this.gas, + required this.turnAt, + required this.minGas, + required this.base, + }); +} + +/// Renders a plan as a high-contrast printable dive slate: runtime table, +/// gas plan, contingency tables, bailout summary, and range table. Pure +/// consumer of engine outputs — no deco math of its own. +class PlanSlatePdfService { + const PlanSlatePdfService(); + + Future> buildSlate({ + required domain.DivePlan plan, + required PlanOutcome outcome, + required List deviations, + required List lostGas, + required RangeTable? rangeTable, + required BailoutOutcome? bailout, + required UnitFormatter units, + required PlanSlateLabels labels, + }) async { + await PdfFonts.instance.initialize(); + final pdf = pw.Document(theme: PdfFonts.instance.theme); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(28), + build: (context) => [ + _header(plan, outcome, units), + pw.SizedBox(height: 10), + _sectionTitle(labels.runtimeTable), + _runtimeTable(outcome, units, labels), + pw.SizedBox(height: 10), + _sectionTitle(labels.gasPlan), + _gasTable(plan, outcome, units, labels), + if (bailout != null) ...[ + pw.SizedBox(height: 10), + _sectionTitle(labels.bailout), + _bailoutSummary(bailout, units), + ], + if (deviations.isNotEmpty || lostGas.isNotEmpty) ...[ + pw.SizedBox(height: 10), + _sectionTitle(labels.contingencies), + for (final deviation in deviations) ...[ + _subTitle(_deviationLabel(deviation, plan, units)), + _runtimeTable(deviation.outcome, units, labels), + pw.SizedBox(height: 6), + ], + for (final lost in lostGas) ...[ + _subTitle(labels.lostGasLabel(lost.tank.gasMix.name)), + _runtimeTable(lost.outcome, units, labels), + pw.SizedBox(height: 6), + ], + ], + if (rangeTable != null && !rangeTable.isEmpty) ...[ + pw.SizedBox(height: 10), + _sectionTitle(labels.rangeTable), + _rangeGrid(rangeTable, units, labels), + ], + ], + ), + ); + + return pdf.save(); + } + + String _deviationLabel( + DeviationOutcome deviation, + domain.DivePlan plan, + UnitFormatter units, + ) { + final depth = + '+${units.formatDepth(plan.deviationDepthDelta, decimals: 0)}'; + final time = '+${plan.deviationTimeMinutes} min'; + return switch (deviation.key) { + 'deeper' => depth, + 'longer' => time, + _ => '$depth $time', + }; + } + + pw.Widget _header( + domain.DivePlan plan, + PlanOutcome outcome, + UnitFormatter units, + ) { + final mode = plan.mode == domain.PlanMode.ccr ? 'CCR' : 'OC'; + final date = + '${plan.updatedAt.year}-${plan.updatedAt.month.toString().padLeft(2, '0')}-${plan.updatedAt.day.toString().padLeft(2, '0')}'; + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + plan.name, + style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 2), + pw.Text( + '$date $mode GF ${plan.gfLow}/${plan.gfHigh} ' + 'max ${units.formatDepth(outcome.maxDepth)} ' + 'RT ${_minutes(outcome.runtimeSeconds)} ' + 'CNS ${outcome.cnsEnd.toStringAsFixed(0)}%', + style: const pw.TextStyle(fontSize: 10), + ), + pw.Divider(thickness: 1.2), + ], + ); + } + + pw.Widget _sectionTitle(String text) => pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 3), + child: pw.Text( + text.toUpperCase(), + style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), + ), + ); + + pw.Widget _subTitle(String text) => pw.Padding( + padding: const pw.EdgeInsets.only(top: 3, bottom: 2), + child: pw.Text( + text, + style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold), + ), + ); + + String _minutes(int seconds) => '${(seconds / 60).ceil()} min'; + + pw.Widget _runtimeTable( + PlanOutcome outcome, + UnitFormatter units, + PlanSlateLabels labels, + ) { + pw.Widget cell(String text, {bool header = false}) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: pw.Text( + text, + style: pw.TextStyle( + fontSize: 9, + fontWeight: header ? pw.FontWeight.bold : null, + ), + ), + ); + + return pw.Table( + border: pw.TableBorder.all(width: 0.4), + columnWidths: const { + 0: pw.FlexColumnWidth(1.2), + 1: pw.FlexColumnWidth(1), + 2: pw.FlexColumnWidth(1), + 3: pw.FlexColumnWidth(1.4), + }, + children: [ + pw.TableRow( + children: [ + cell(labels.depth, header: true), + cell(labels.stop, header: true), + cell(labels.runtime, header: true), + cell(labels.gas, header: true), + ], + ), + for (final stop in outcome.stops) + pw.TableRow( + children: [ + cell(units.formatDepth(stop.depthMeters, decimals: 0)), + cell('${(stop.durationSeconds / 60).ceil()}'), + cell( + '${((stop.arrivalRuntimeSeconds + stop.durationSeconds) / 60).ceil()}', + ), + cell( + GasMix(o2: stop.gasFO2 * 100.0, he: stop.gasFHe * 100.0).name, + ), + ], + ), + ], + ); + } + + pw.Widget _gasTable( + domain.DivePlan plan, + PlanOutcome outcome, + UnitFormatter units, + PlanSlateLabels labels, + ) { + String tankLabel(String tankId) { + final tank = plan.tanks.where((t) => t.id == tankId).firstOrNull; + if (tank == null) return '--'; + return tank.name ?? tank.gasMix.name; + } + + pw.Widget cell(String text, {bool header = false}) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: pw.Text( + text, + style: pw.TextStyle( + fontSize: 9, + fontWeight: header ? pw.FontWeight.bold : null, + ), + ), + ); + + return pw.Table( + border: pw.TableBorder.all(width: 0.4), + children: [ + pw.TableRow( + children: [ + cell(labels.gas, header: true), + cell('L', header: true), + cell('%', header: true), + cell(labels.turnAt, header: true), + cell(labels.minGas, header: true), + ], + ), + for (final usage in outcome.tankUsages) + pw.TableRow( + children: [ + cell(tankLabel(usage.tankId)), + cell(usage.litersUsed.toStringAsFixed(0)), + cell('${usage.percentUsed.toStringAsFixed(0)}%'), + cell( + usage.turnPressureBar != null + ? units.formatPressure(usage.turnPressureBar!, decimals: 0) + : '--', + ), + cell( + usage.minGasBar != null + ? units.formatPressure(usage.minGasBar!, decimals: 0) + : '--', + ), + ], + ), + ], + ); + } + + pw.Widget _bailoutSummary(BailoutOutcome bailout, UnitFormatter units) { + final worst = bailout.worstCase; + return pw.Text( + 'TTS ${_minutes(worst.ttsSeconds)} @ ${units.formatDepth(worst.depthMeters)} ' + '(${worst.litersRequired.toStringAsFixed(0)} L / ' + '${bailout.availableLiters.toStringAsFixed(0)} L)', + style: const pw.TextStyle(fontSize: 10), + ); + } + + pw.Widget _rangeGrid( + RangeTable table, + UnitFormatter units, + PlanSlateLabels labels, + ) { + pw.Widget cell(String text, {bool header = false, bool flag = false}) => + pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: pw.Text( + text, + style: pw.TextStyle( + fontSize: 9, + fontWeight: header || flag ? pw.FontWeight.bold : null, + color: flag ? PdfColors.red900 : null, + ), + ), + ); + + String depthLabel(double delta) => delta == 0 + ? labels.base + : '${delta > 0 ? '+' : '-'}${units.formatDepth(delta.abs(), decimals: 0)}'; + String timeLabel(int delta) => + delta == 0 ? labels.base : '${delta > 0 ? '+' : '-'}${delta.abs()} min'; + + return pw.Table( + border: pw.TableBorder.all(width: 0.4), + children: [ + pw.TableRow( + children: [ + cell('', header: true), + for (final timeDelta in table.timeDeltas) + cell(timeLabel(timeDelta), header: true), + ], + ), + for (var d = 0; d < table.depthDeltas.length; d++) + pw.TableRow( + children: [ + cell(depthLabel(table.depthDeltas[d]), header: true), + for (final rangeCell in table.cells[d]) + if (rangeCell == null) + cell('--') + else + cell( + '${(rangeCell.outcome.ttsAtBottom / 60).ceil()}', + flag: !rangeCell.outcome.isDiveable, + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart index 379bad205..2864ed5d9 100644 --- a/lib/features/planner/presentation/pages/plan_canvas_page.dart +++ b/lib/features/planner/presentation/pages/plan_canvas_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/export/shared/file_export_utils.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; @@ -10,8 +12,10 @@ import 'package:submersion/features/dive_planner/presentation/widgets/plan_tank_ import 'package:submersion/features/dive_planner/presentation/widgets/segment_list.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/simple_plan_dialog.dart'; import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/data/services/plan_slate_pdf_service.dart'; import 'package:submersion/features/planner/domain/entities/dive_plan.dart' as domain; +import 'package:submersion/features/planner/domain/services/dive_plan_state_mapper.dart'; import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart'; import 'package:submersion/features/planner/presentation/widgets/ccr_settings_section.dart'; import 'package:submersion/features/planner/presentation/widgets/contingency_chips.dart'; @@ -21,6 +25,7 @@ import 'package:submersion/features/planner/presentation/widgets/plan_canvas_cha 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/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart'; @@ -128,6 +133,11 @@ class _PlanCanvasPageState extends ConsumerState { Icons.scuba_diving, context.l10n.divePlanner_action_convertToDive, ), + _menuItem( + 'slate', + Icons.picture_as_pdf, + context.l10n.plannerCanvas_slate_menu, + ), _menuItem( 'reset', Icons.refresh, @@ -167,11 +177,53 @@ class _PlanCanvasPageState extends ConsumerState { _showSettingsSheet(context); case 'convert': _convertToDive(); + case 'slate': + _exportSlate(); case 'reset': _resetPlan(); } } + Future _exportSlate() async { + final l10n = context.l10n; + final state = ref.read(divePlanNotifierProvider); + final labels = PlanSlateLabels( + runtimeTable: l10n.divePlanner_label_decoSchedule, + gasPlan: l10n.divePlanner_label_gasConsumption, + contingencies: l10n.plannerCanvas_contingency_title, + lostGasLabel: l10n.plannerCanvas_contingency_lostGas, + rangeTable: l10n.plannerCanvas_range_title, + bailout: l10n.plannerCanvas_bailout_title, + stop: l10n.plannerCanvas_table_stop, + depth: l10n.plannerCanvas_table_depth, + runtime: l10n.plannerCanvas_table_runtime, + gas: l10n.plannerCanvas_table_gas, + turnAt: l10n.plannerCanvas_slate_turn, + minGas: l10n.plannerCanvas_slate_minGas, + base: l10n.plannerCanvas_range_base, + ); + + final bytes = await const PlanSlatePdfService().buildSlate( + plan: divePlanFromState(state), + outcome: ref.read(planOutcomeProvider), + deviations: ref.read(planDeviationsProvider), + lostGas: ref.read(planLostGasProvider), + rangeTable: ref.read(planRangeTableProvider), + bailout: ref.read(planBailoutProvider), + units: UnitFormatter(ref.read(settingsProvider)), + labels: labels, + ); + + final safeName = state.name + .replaceAll(RegExp(r'[^\w\s-]'), '') + .trim() + .replaceAll(RegExp(r'\s+'), '_'); + await sharePdfBytes( + bytes, + '${safeName.isEmpty ? 'dive_plan' : safeName}_slate.pdf', + ); + } + // --- Phone: chart + chips + editor list, results in a draggable sheet --- Widget _buildPhone() { diff --git a/lib/features/planner/presentation/widgets/range_table_section.dart b/lib/features/planner/presentation/widgets/range_table_section.dart index d93280d48..6ab8b9624 100644 --- a/lib/features/planner/presentation/widgets/range_table_section.dart +++ b/lib/features/planner/presentation/widgets/range_table_section.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; -import 'package:submersion/features/planner/domain/services/range_table_service.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'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index fa8cacb31..ae77d92a9 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5772,6 +5772,9 @@ "depth": { "type": "String" } } }, + "plannerCanvas_slate_menu": "Export slate (PDF)", + "plannerCanvas_slate_minGas": "Min gas", + "plannerCanvas_slate_turn": "Turn", "plannerCanvas_table_depth": "Depth", "plannerCanvas_table_gas": "Gas", "plannerCanvas_table_runtime": "RT", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 08f129adb..1b6779ec5 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17918,6 +17918,24 @@ abstract class AppLocalizations { /// **'RT {minutes}′ · {depth}'** String plannerCanvas_scrub_readout(String minutes, String depth); + /// No description provided for @plannerCanvas_slate_menu. + /// + /// In en, this message translates to: + /// **'Export slate (PDF)'** + String get plannerCanvas_slate_menu; + + /// No description provided for @plannerCanvas_slate_minGas. + /// + /// In en, this message translates to: + /// **'Min gas'** + String get plannerCanvas_slate_minGas; + + /// No description provided for @plannerCanvas_slate_turn. + /// + /// In en, this message translates to: + /// **'Turn'** + String get plannerCanvas_slate_turn; + /// No description provided for @plannerCanvas_table_depth. /// /// 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 0d9861aab..5bbd0b5fd 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10342,6 +10342,15 @@ class AppLocalizationsAr extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'العمق'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index b75f104fc..155756ad0 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10536,6 +10536,15 @@ class AppLocalizationsDe extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Tiefe'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index a79a9c660..2da862c2b 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10375,6 +10375,15 @@ class AppLocalizationsEn extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Depth'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 2edfd89db..8adf9e991 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10527,6 +10527,15 @@ class AppLocalizationsEs extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Profundidad'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 261d11313..6597886f5 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10577,6 +10577,15 @@ class AppLocalizationsFr extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Profondeur'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 803603370..b23a50600 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10275,6 +10275,15 @@ class AppLocalizationsHe extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'עומק'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 57112c983..b1f097339 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10512,6 +10512,15 @@ class AppLocalizationsHu extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Mélység'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 3e121e9c5..18dad1bb8 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10542,6 +10542,15 @@ class AppLocalizationsIt extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Profondità'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c7a690b18..3bc40c478 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10463,6 +10463,15 @@ class AppLocalizationsNl extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Diepte'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 8ac7d1570..1d1f46436 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10548,6 +10548,15 @@ class AppLocalizationsPt extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Profundidade'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index ed2b86feb..72fbe63d2 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -10086,6 +10086,15 @@ class AppLocalizationsZh extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => '深度'; diff --git a/test/features/planner/plan_slate_pdf_test.dart b/test/features/planner/plan_slate_pdf_test.dart new file mode 100644 index 000000000..8ca120b41 --- /dev/null +++ b/test/features/planner/plan_slate_pdf_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.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/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/data/services/plan_slate_pdf_service.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/services/bailout_solver.dart'; +import 'package:submersion/features/planner/domain/services/contingency_service.dart'; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; +import 'package:submersion/features/planner/domain/services/range_table_service.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +const _air = GasMix(o2: 21); +const _ean50 = GasMix(o2: 50); +const _airTank = DiveTank( + id: 'tank-1', + volume: 11.1, + startPressure: 207.0, + gasMix: _air, +); +const _decoTank = DiveTank( + id: 'tank-2', + volume: 7.0, + startPressure: 200.0, + gasMix: _ean50, + role: TankRole.deco, +); + +final _labels = PlanSlateLabels( + runtimeTable: 'Deco schedule', + gasPlan: 'Gas plan', + contingencies: 'Contingencies', + lostGasLabel: (gas) => 'Lost $gas', + rangeTable: 'Range table', + bailout: 'Bailout', + stop: 'Stop', + depth: 'Depth', + runtime: 'RT', + gas: 'Gas', + turnAt: 'Turn', + minGas: 'Min gas', + base: 'Base', +); + +domain.DivePlan _plan({domain.PlanMode mode = domain.PlanMode.oc}) { + return domain.DivePlan( + id: 'plan-1', + name: 'Slate test', + gfLow: 40, + gfHigh: 80, + mode: mode, + turnPressureRule: domain.TurnPressureRule.thirds, + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: 45.0, + tankId: 'tank-1', + gasMix: _air, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: 45.0, + durationMinutes: 25, + tankId: 'tank-1', + gasMix: _air, + order: 1, + ), + ], + tanks: const [_airTank, _decoTank], + createdAt: DateTime(2026, 7, 5), + updatedAt: DateTime(2026, 7, 5), + ); +} + +Future> _build(domain.DivePlan plan) { + const engine = PlanEngine(); + const contingency = ContingencyService(); + const rangeService = RangeTableService(); + final outcome = engine.compute(plan); + return const PlanSlatePdfService().buildSlate( + plan: plan, + outcome: outcome, + deviations: contingency.deviations(plan), + lostGas: contingency.lostGas(plan), + rangeTable: rangeService.compute(plan), + bailout: const BailoutSolver().solve(plan), + units: UnitFormatter(const AppSettings()), + labels: _labels, + ); +} + +void main() { + test('OC slate renders a parseable PDF with all sections', () async { + final bytes = await _build(_plan()); + expect(bytes, isNotEmpty); + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + }); + + test('CCR slate (bailout section) renders without throwing', () async { + final bytes = await _build(_plan(mode: domain.PlanMode.ccr)); + expect(bytes, isNotEmpty); + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + }); +} From e9376b3ec1d875f50fd30980a9832088941306aa Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:25:55 -0400 Subject: [PATCH 5/9] feat(planner): versioned .subplan share file with import --- .../data/services/plan_file_codec.dart | 203 ++++++++++++++++++ .../presentation/pages/plan_canvas_page.dart | 22 ++ .../widgets/saved_plans_sheet.dart | 63 +++++- lib/l10n/arb/app_en.arb | 8 + lib/l10n/arb/app_localizations.dart | 18 ++ lib/l10n/arb/app_localizations_ar.dart | 11 + lib/l10n/arb/app_localizations_de.dart | 11 + lib/l10n/arb/app_localizations_en.dart | 11 + lib/l10n/arb/app_localizations_es.dart | 11 + lib/l10n/arb/app_localizations_fr.dart | 11 + lib/l10n/arb/app_localizations_he.dart | 11 + lib/l10n/arb/app_localizations_hu.dart | 11 + lib/l10n/arb/app_localizations_it.dart | 11 + lib/l10n/arb/app_localizations_nl.dart | 11 + lib/l10n/arb/app_localizations_pt.dart | 11 + lib/l10n/arb/app_localizations_zh.dart | 11 + .../planner/plan_file_codec_test.dart | 144 +++++++++++++ 17 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 lib/features/planner/data/services/plan_file_codec.dart create mode 100644 test/features/planner/plan_file_codec_test.dart diff --git a/lib/features/planner/data/services/plan_file_codec.dart b/lib/features/planner/data/services/plan_file_codec.dart new file mode 100644 index 000000000..ee4187aaf --- /dev/null +++ b/lib/features/planner/data/services/plan_file_codec.dart @@ -0,0 +1,203 @@ +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; + +/// The `.subplan` file format: versioned JSON around the plan aggregate. +/// Version 1 carries every engine-relevant input; schedules are never +/// exported — the importing install recomputes them. +const subplanFormat = 'submersion-plan'; +const subplanVersion = 1; +const subplanExtension = 'subplan'; + +const _uuid = Uuid(); + +/// Serializes [plan] into a shareable `.subplan` JSON string. +String planToSubplanJson(domain.DivePlan plan) { + final map = { + 'format': subplanFormat, + 'version': subplanVersion, + 'plan': { + 'name': plan.name, + 'notes': plan.notes, + 'mode': plan.mode.name, + 'altitude': plan.altitude, + 'waterType': plan.waterType?.name, + 'gfLow': plan.gfLow, + 'gfHigh': plan.gfHigh, + 'descentRate': plan.descentRate, + 'ascentRate': plan.ascentRate, + 'lastStopDepth': plan.lastStopDepth, + 'gasSwitchStopSeconds': plan.gasSwitchStopSeconds, + 'airBreaks': plan.airBreaks == null + ? null + : { + 'o2Seconds': plan.airBreaks!.o2Seconds, + 'breakSeconds': plan.airBreaks!.breakSeconds, + }, + 'sacBottom': plan.sacBottom, + 'sacDeco': plan.sacDeco, + 'sacStressed': plan.sacStressed, + 'reservePressure': plan.reservePressure, + 'setpointLow': plan.setpointLow, + 'setpointHigh': plan.setpointHigh, + 'setpointSwitchDepth': plan.setpointSwitchDepth, + 'deviationDepthDelta': plan.deviationDepthDelta, + 'deviationTimeMinutes': plan.deviationTimeMinutes, + 'turnPressureRule': plan.turnPressureRule?.name, + 'turnPressureFraction': plan.turnPressureFraction, + 'tanks': [ + for (final tank in plan.tanks) + { + 'key': tank.id, + 'name': tank.name, + 'volume': tank.volume, + 'workingPressure': tank.workingPressure, + 'startPressure': tank.startPressure, + 'o2': tank.gasMix.o2, + 'he': tank.gasMix.he, + 'role': tank.role.name, + 'order': tank.order, + }, + ], + 'segments': [ + for (final segment in plan.segments) + { + 'type': segment.type.name, + 'startDepth': segment.startDepth, + 'endDepth': segment.endDepth, + 'durationSeconds': segment.durationSeconds, + 'tankKey': segment.tankId, + 'o2': segment.gasMix.o2, + 'he': segment.gasMix.he, + 'rate': segment.rate, + 'order': segment.order, + }, + ], + }, + }; + return const JsonEncoder.withIndent(' ').convert(map); +} + +/// Parses a `.subplan` JSON string into a fresh [domain.DivePlan]. +/// +/// All ids are regenerated (tank references are remapped through the +/// exported keys) so importing can never collide with existing rows. +/// Throws [FormatException] on a foreign format or a newer version. +domain.DivePlan subplanFromJson(String source, {DateTime? now}) { + final Object? decoded; + try { + decoded = jsonDecode(source); + } on FormatException { + throw const FormatException('Not a valid .subplan file'); + } + if (decoded is! Map || decoded['format'] != subplanFormat) { + throw const FormatException('Not a Submersion plan file'); + } + final version = decoded['version']; + if (version is! int || version > subplanVersion) { + throw FormatException( + 'Plan file version $version is newer than this app supports', + ); + } + final plan = decoded['plan']; + if (plan is! Map) { + throw const FormatException('Plan file carries no plan'); + } + + final timestamp = now ?? DateTime.now(); + + // Fresh tank ids, remembering the export keys for segment references. + final tankIdByKey = {}; + final tanks = []; + for (final (index, raw) in (plan['tanks'] as List? ?? const []).indexed) { + final tank = raw as Map; + final id = _uuid.v4(); + tankIdByKey[tank['key'] as String] = id; + tanks.add( + DiveTank( + id: id, + name: tank['name'] as String?, + volume: (tank['volume'] as num?)?.toDouble(), + workingPressure: (tank['workingPressure'] as num?)?.toDouble(), + startPressure: (tank['startPressure'] as num?)?.toDouble(), + gasMix: GasMix( + o2: (tank['o2'] as num).toDouble(), + he: (tank['he'] as num?)?.toDouble() ?? 0.0, + ), + role: TankRole.values.asNameMap()[tank['role']] ?? TankRole.backGas, + order: (tank['order'] as num?)?.toInt() ?? index, + ), + ); + } + + final segments = []; + for (final (index, raw) in (plan['segments'] as List? ?? const []).indexed) { + final segment = raw as Map; + segments.add( + PlanSegment( + id: _uuid.v4(), + type: + SegmentType.values.asNameMap()[segment['type']] ?? + SegmentType.bottom, + startDepth: (segment['startDepth'] as num).toDouble(), + endDepth: (segment['endDepth'] as num).toDouble(), + durationSeconds: (segment['durationSeconds'] as num).toInt(), + tankId: tankIdByKey[segment['tankKey']] ?? '', + gasMix: GasMix( + o2: (segment['o2'] as num).toDouble(), + he: (segment['he'] as num?)?.toDouble() ?? 0.0, + ), + rate: (segment['rate'] as num?)?.toDouble(), + order: (segment['order'] as num?)?.toInt() ?? index, + ), + ); + } + + final airBreaks = plan['airBreaks']; + + return domain.DivePlan( + id: _uuid.v4(), + name: plan['name'] as String? ?? 'Imported plan', + notes: plan['notes'] as String? ?? '', + createdAt: timestamp, + updatedAt: timestamp, + mode: + domain.PlanMode.values.asNameMap()[plan['mode']] ?? domain.PlanMode.oc, + altitude: (plan['altitude'] as num?)?.toDouble(), + waterType: WaterType.values.asNameMap()[plan['waterType']], + gfLow: (plan['gfLow'] as num).toInt(), + gfHigh: (plan['gfHigh'] as num).toInt(), + descentRate: (plan['descentRate'] as num?)?.toDouble() ?? 18.0, + ascentRate: (plan['ascentRate'] as num?)?.toDouble() ?? 9.0, + lastStopDepth: (plan['lastStopDepth'] as num?)?.toDouble() ?? 3.0, + gasSwitchStopSeconds: (plan['gasSwitchStopSeconds'] as num?)?.toInt() ?? 0, + airBreaks: airBreaks is Map + ? AirBreakPolicy( + o2Seconds: (airBreaks['o2Seconds'] as num).toInt(), + breakSeconds: (airBreaks['breakSeconds'] as num).toInt(), + ) + : null, + sacBottom: (plan['sacBottom'] as num?)?.toDouble() ?? 15.0, + sacDeco: (plan['sacDeco'] as num?)?.toDouble(), + sacStressed: (plan['sacStressed'] as num?)?.toDouble(), + reservePressure: (plan['reservePressure'] as num?)?.toDouble() ?? 50.0, + setpointLow: (plan['setpointLow'] as num?)?.toDouble(), + setpointHigh: (plan['setpointHigh'] as num?)?.toDouble(), + setpointSwitchDepth: (plan['setpointSwitchDepth'] as num?)?.toDouble(), + deviationDepthDelta: + (plan['deviationDepthDelta'] as num?)?.toDouble() ?? 5.0, + deviationTimeMinutes: (plan['deviationTimeMinutes'] as num?)?.toInt() ?? 5, + turnPressureRule: domain.TurnPressureRule.values + .asNameMap()[plan['turnPressureRule']], + turnPressureFraction: (plan['turnPressureFraction'] as num?)?.toDouble(), + tanks: tanks, + segments: segments, + ); +} diff --git a/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart index 2864ed5d9..e5c63a1a0 100644 --- a/lib/features/planner/presentation/pages/plan_canvas_page.dart +++ b/lib/features/planner/presentation/pages/plan_canvas_page.dart @@ -12,6 +12,7 @@ import 'package:submersion/features/dive_planner/presentation/widgets/plan_tank_ import 'package:submersion/features/dive_planner/presentation/widgets/segment_list.dart'; import 'package:submersion/features/dive_planner/presentation/widgets/simple_plan_dialog.dart'; import 'package:submersion/features/planner/data/repositories/dive_plan_repository.dart'; +import 'package:submersion/features/planner/data/services/plan_file_codec.dart'; import 'package:submersion/features/planner/data/services/plan_slate_pdf_service.dart'; import 'package:submersion/features/planner/domain/entities/dive_plan.dart' as domain; @@ -138,6 +139,11 @@ class _PlanCanvasPageState extends ConsumerState { Icons.picture_as_pdf, context.l10n.plannerCanvas_slate_menu, ), + _menuItem( + 'share', + Icons.ios_share, + context.l10n.plannerCanvas_share_menu, + ), _menuItem( 'reset', Icons.refresh, @@ -179,11 +185,27 @@ class _PlanCanvasPageState extends ConsumerState { _convertToDive(); case 'slate': _exportSlate(); + case 'share': + _sharePlanFile(); case 'reset': _resetPlan(); } } + Future _sharePlanFile() async { + final state = ref.read(divePlanNotifierProvider); + final json = planToSubplanJson(divePlanFromState(state)); + final safeName = state.name + .replaceAll(RegExp(r'[^\w\s-]'), '') + .trim() + .replaceAll(RegExp(r'\s+'), '_'); + await saveAndShareFile( + json, + '${safeName.isEmpty ? 'dive_plan' : safeName}.$subplanExtension', + 'application/json', + ); + } + Future _exportSlate() async { final l10n = context.l10n; final state = ref.read(divePlanNotifierProvider); diff --git a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart index fbbd135e8..f90593e19 100644 --- a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart +++ b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart @@ -1,8 +1,13 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/export/shared/file_export_utils.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/planner/data/services/plan_file_codec.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'; @@ -38,9 +43,20 @@ class SavedPlansSheet extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - context.l10n.plannerCanvas_saved_title, - style: theme.textTheme.titleMedium, + Row( + children: [ + Expanded( + child: Text( + context.l10n.plannerCanvas_saved_title, + style: theme.textTheme.titleMedium, + ), + ), + TextButton.icon( + icon: const Icon(Icons.file_open, size: 18), + label: Text(context.l10n.plannerCanvas_share_import), + onPressed: () => _importPlan(context, ref), + ), + ], ), const SizedBox(height: 12), if (plans == null && summaries.isLoading) @@ -72,6 +88,31 @@ class SavedPlansSheet extends ConsumerWidget { ), ); } + + Future _importPlan(BuildContext context, WidgetRef ref) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + final router = GoRouter.of(context); + final navigator = Navigator.of(context); + + final result = await FilePicker.pickFiles(type: FileType.any); + final path = result?.files.single.path; + if (path == null) return; + + try { + final source = await File(path).readAsString(); + final plan = subplanFromJson(source); + await ref.read(divePlanRepositoryProvider).savePlan(plan); + navigator.pop(); + router.go('/planning/dive-planner/${plan.id}'); + } on FormatException catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text(l10n.plannerCanvas_share_importFailed(e.message)), + ), + ); + } + } } class _PlanTile extends ConsumerWidget { @@ -105,6 +146,18 @@ class _PlanTile extends ConsumerWidget { final repository = ref.read(divePlanRepositoryProvider); if (value == 'duplicate') { await repository.duplicatePlan(summary.id); + } else if (value == 'share') { + final plan = await repository.getPlan(summary.id); + if (plan == null) return; + final safeName = plan.name + .replaceAll(RegExp(r'[^\w\s-]'), '') + .trim() + .replaceAll(RegExp(r'\s+'), '_'); + await saveAndShareFile( + planToSubplanJson(plan), + '${safeName.isEmpty ? 'dive_plan' : safeName}.$subplanExtension', + 'application/json', + ); } else if (value == 'delete') { final confirmed = await _confirmDelete(context); if (confirmed) await repository.deletePlan(summary.id); @@ -115,6 +168,10 @@ class _PlanTile extends ConsumerWidget { value: 'duplicate', child: Text(context.l10n.plannerCanvas_saved_duplicate), ), + PopupMenuItem( + value: 'share', + child: Text(context.l10n.plannerCanvas_share_menu), + ), PopupMenuItem( value: 'delete', child: Text(context.l10n.common_action_delete), diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ae77d92a9..a1e9942ca 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5772,6 +5772,14 @@ "depth": { "type": "String" } } }, + "plannerCanvas_share_import": "Import", + "plannerCanvas_share_importFailed": "Couldn't import plan: {reason}", + "@plannerCanvas_share_importFailed": { + "placeholders": { + "reason": { "type": "String" } + } + }, + "plannerCanvas_share_menu": "Share plan file", "plannerCanvas_slate_menu": "Export slate (PDF)", "plannerCanvas_slate_minGas": "Min gas", "plannerCanvas_slate_turn": "Turn", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 1b6779ec5..283e9441a 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17918,6 +17918,24 @@ abstract class AppLocalizations { /// **'RT {minutes}′ · {depth}'** String plannerCanvas_scrub_readout(String minutes, String depth); + /// No description provided for @plannerCanvas_share_import. + /// + /// In en, this message translates to: + /// **'Import'** + String get plannerCanvas_share_import; + + /// No description provided for @plannerCanvas_share_importFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t import plan: {reason}'** + String plannerCanvas_share_importFailed(String reason); + + /// No description provided for @plannerCanvas_share_menu. + /// + /// In en, this message translates to: + /// **'Share plan file'** + String get plannerCanvas_share_menu; + /// No description provided for @plannerCanvas_slate_menu. /// /// 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 5bbd0b5fd..bfc21e1bc 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10342,6 +10342,17 @@ class AppLocalizationsAr extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 155756ad0..7a5b6d657 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10536,6 +10536,17 @@ class AppLocalizationsDe extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 2da862c2b..d7812f49b 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10375,6 +10375,17 @@ class AppLocalizationsEn extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 8adf9e991..4d12bc4dc 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10527,6 +10527,17 @@ class AppLocalizationsEs extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 6597886f5..b2bc77bf8 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10577,6 +10577,17 @@ class AppLocalizationsFr extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index b23a50600..edd544ee4 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10275,6 +10275,17 @@ class AppLocalizationsHe extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index b1f097339..6db2d101c 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10512,6 +10512,17 @@ class AppLocalizationsHu extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 18dad1bb8..31e5179f3 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10542,6 +10542,17 @@ class AppLocalizationsIt extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 3bc40c478..7a7eedaf6 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10463,6 +10463,17 @@ class AppLocalizationsNl extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 1d1f46436..4333429ca 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10548,6 +10548,17 @@ class AppLocalizationsPt extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 72fbe63d2..91a608aef 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -10086,6 +10086,17 @@ class AppLocalizationsZh extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Import'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Couldn\'t import plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Share plan file'; + @override String get plannerCanvas_slate_menu => 'Export slate (PDF)'; diff --git a/test/features/planner/plan_file_codec_test.dart b/test/features/planner/plan_file_codec_test.dart new file mode 100644 index 000000000..99bea5fcf --- /dev/null +++ b/test/features/planner/plan_file_codec_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/deco/schedule_policy.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_planner/domain/entities/plan_segment.dart'; +import 'package:submersion/features/planner/data/services/plan_file_codec.dart'; +import 'package:submersion/features/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; + +domain.DivePlan _plan() { + const air = GasMix(o2: 21, he: 0); + const trimix = GasMix(o2: 18, he: 45); + return domain.DivePlan( + id: 'plan-1', + name: 'Codec test', + notes: 'Some notes', + mode: domain.PlanMode.ccr, + altitude: 1200.0, + waterType: WaterType.fresh, + gfLow: 35, + gfHigh: 75, + descentRate: 20.0, + ascentRate: 10.0, + lastStopDepth: 6.0, + gasSwitchStopSeconds: 60, + airBreaks: const AirBreakPolicy(o2Seconds: 900, breakSeconds: 300), + sacBottom: 16.0, + sacDeco: 13.0, + reservePressure: 60.0, + setpointLow: 0.7, + setpointHigh: 1.4, + setpointSwitchDepth: 12.0, + deviationDepthDelta: 6.0, + deviationTimeMinutes: 10, + turnPressureRule: domain.TurnPressureRule.thirds, + tanks: const [ + DiveTank( + id: 'tank-1', + name: 'Diluent', + volume: 3.0, + startPressure: 200.0, + gasMix: trimix, + role: TankRole.diluent, + ), + DiveTank( + id: 'tank-2', + volume: 11.1, + startPressure: 207.0, + gasMix: air, + role: TankRole.bailout, + order: 1, + ), + ], + segments: [ + PlanSegment.descent( + id: 'seg-1', + targetDepth: 50.0, + tankId: 'tank-1', + gasMix: trimix, + order: 0, + ), + PlanSegment.bottom( + id: 'seg-2', + depth: 50.0, + durationMinutes: 30, + tankId: 'tank-1', + gasMix: trimix, + order: 1, + ), + ], + createdAt: DateTime(2026, 7, 1), + updatedAt: DateTime(2026, 7, 5), + ); +} + +void main() { + test('round-trip preserves every engine-relevant field', () { + final original = _plan(); + final restored = subplanFromJson(planToSubplanJson(original)); + + // Fresh identity... + expect(restored.id, isNot(original.id)); + // ...same inputs. + expect(restored.name, original.name); + expect(restored.notes, original.notes); + expect(restored.mode, original.mode); + expect(restored.altitude, original.altitude); + expect(restored.waterType, original.waterType); + expect(restored.gfLow, original.gfLow); + expect(restored.gfHigh, original.gfHigh); + expect(restored.descentRate, original.descentRate); + expect(restored.ascentRate, original.ascentRate); + expect(restored.lastStopDepth, original.lastStopDepth); + expect(restored.gasSwitchStopSeconds, original.gasSwitchStopSeconds); + expect(restored.airBreaks?.o2Seconds, original.airBreaks?.o2Seconds); + expect(restored.sacBottom, original.sacBottom); + expect(restored.sacDeco, original.sacDeco); + expect(restored.reservePressure, original.reservePressure); + expect(restored.setpointHigh, original.setpointHigh); + expect(restored.setpointSwitchDepth, original.setpointSwitchDepth); + expect(restored.deviationDepthDelta, original.deviationDepthDelta); + expect(restored.deviationTimeMinutes, original.deviationTimeMinutes); + expect(restored.turnPressureRule, original.turnPressureRule); + + expect(restored.tanks, hasLength(2)); + expect(restored.tanks.first.gasMix, original.tanks.first.gasMix); + expect(restored.tanks.first.role, TankRole.diluent); + expect(restored.tanks[1].role, TankRole.bailout); + + expect(restored.segments, hasLength(2)); + expect(restored.segments[1].type, SegmentType.bottom); + expect(restored.segments[1].durationSeconds, 30 * 60); + // Segment tank references remap onto the regenerated tank ids. + expect(restored.segments.first.tankId, restored.tanks.first.id); + + // The engine computes the same schedule for both. + const engine = PlanEngine(); + final a = engine.compute(original); + final b = engine.compute(restored); + expect(b.ttsAtBottom, a.ttsAtBottom); + expect(b.stops.length, a.stops.length); + }); + + test('foreign or future files are rejected', () { + expect(() => subplanFromJson('not json'), throwsFormatException); + expect( + () => subplanFromJson(jsonEncode({'format': 'other', 'version': 1})), + throwsFormatException, + ); + expect( + () => subplanFromJson( + jsonEncode({ + 'format': subplanFormat, + 'version': subplanVersion + 1, + 'plan': {}, + }), + ), + throwsFormatException, + ); + }); +} From f28aec7a2dd9dd5a0cafeafb916edaf3ceeccb48 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:30:04 -0400 Subject: [PATCH 6/9] feat(planner): multi-plan compare with overlaid profiles and diff table --- lib/core/router/app_router.dart | 11 + .../presentation/pages/plan_compare_page.dart | 267 ++++++++++++++++++ .../widgets/saved_plans_sheet.dart | 68 ++++- lib/l10n/arb/app_en.arb | 3 + lib/l10n/arb/app_localizations.dart | 18 ++ lib/l10n/arb/app_localizations_ar.dart | 10 + lib/l10n/arb/app_localizations_de.dart | 10 + lib/l10n/arb/app_localizations_en.dart | 10 + lib/l10n/arb/app_localizations_es.dart | 10 + lib/l10n/arb/app_localizations_fr.dart | 10 + lib/l10n/arb/app_localizations_he.dart | 10 + lib/l10n/arb/app_localizations_hu.dart | 10 + lib/l10n/arb/app_localizations_it.dart | 10 + lib/l10n/arb/app_localizations_nl.dart | 10 + lib/l10n/arb/app_localizations_pt.dart | 10 + lib/l10n/arb/app_localizations_zh.dart | 10 + test/features/planner/plan_compare_test.dart | 92 ++++++ 17 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 lib/features/planner/presentation/pages/plan_compare_page.dart create mode 100644 test/features/planner/plan_compare_test.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index a81970268..22eeaf58b 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -117,6 +117,7 @@ import 'package:submersion/features/dive_log/presentation/providers/dive_compute 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/planner/presentation/pages/plan_canvas_page.dart'; +import 'package:submersion/features/planner/presentation/pages/plan_compare_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'; @@ -208,6 +209,16 @@ final appRouterProvider = Provider((ref) { name: 'divePlanner', builder: (context, state) => const PlanCanvasPage(), routes: [ + GoRoute( + path: 'compare', + name: 'comparePlans', + builder: (context, state) => PlanComparePage( + planIds: (state.uri.queryParameters['ids'] ?? '') + .split(',') + .where((id) => id.isNotEmpty) + .toList(), + ), + ), GoRoute( path: ':planId', name: 'editPlan', diff --git a/lib/features/planner/presentation/pages/plan_compare_page.dart b/lib/features/planner/presentation/pages/plan_compare_page.dart new file mode 100644 index 000000000..b4574ce0c --- /dev/null +++ b/lib/features/planner/presentation/pages/plan_compare_page.dart @@ -0,0 +1,267 @@ +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/planner/domain/entities/dive_plan.dart' + as domain; +import 'package:submersion/features/planner/domain/entities/plan_outcome.dart'; +import 'package:submersion/features/planner/domain/services/plan_engine.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.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'; + +/// One plan in a comparison: the aggregate, its engine outcome, and the +/// chart series derived from both. +class ComparedPlan { + final domain.DivePlan plan; + final PlanOutcome outcome; + final PlanCanvasSeries series; + + const ComparedPlan({ + required this.plan, + required this.outcome, + required this.series, + }); +} + +/// Loads and computes the plans named by a comma-joined id list. Unknown +/// ids are skipped. +final planComparisonProvider = + FutureProvider.family, String>((ref, joinedIds) async { + final repository = ref.watch(divePlanRepositoryProvider); + final config = ref.watch(planEngineConfigProvider); + final engine = PlanEngine(config: config); + + final compared = []; + for (final id in joinedIds.split(',').where((id) => id.isNotEmpty)) { + final plan = await repository.getPlan(id); + if (plan == null) continue; + final outcome = engine.compute(plan); + compared.add( + ComparedPlan( + plan: plan, + outcome: outcome, + series: buildCanvasSeries( + segments: plan.segments, + outcome: outcome, + ), + ), + ); + } + return compared; + }); + +/// Side-by-side comparison of 2-3 saved plans: overlaid computed profiles +/// plus a diff table of the headline numbers. +class PlanComparePage extends ConsumerWidget { + const PlanComparePage({super.key, required this.planIds}); + + final List planIds; + + static const _colors = [Colors.blue, Colors.orange, Colors.purple]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final comparison = ref.watch(planComparisonProvider(planIds.join(','))); + final units = UnitFormatter(ref.watch(settingsProvider)); + + return Scaffold( + appBar: AppBar(title: Text(context.l10n.plannerCanvas_compare_title)), + body: comparison.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e')), + data: (plans) => plans.length < 2 + ? Center(child: Text(context.l10n.plannerCanvas_compare_needTwo)) + : _CompareBody(plans: plans, units: units, colors: _colors), + ), + ); + } +} + +class _CompareBody extends StatelessWidget { + const _CompareBody({ + required this.plans, + required this.units, + required this.colors, + }); + + final List plans; + final UnitFormatter units; + final List colors; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + SizedBox(height: 260, child: _chart(context)), + const SizedBox(height: 8), + Wrap( + spacing: 12, + children: [ + for (final (index, compared) in plans.indexed) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colors[index % colors.length], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + compared.plan.name, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ], + ), + const SizedBox(height: 16), + _diffTable(context), + ], + ); + } + + Widget _chart(BuildContext context) { + final theme = Theme.of(context); + var maxTime = 0.0; + var maxDepth = 0.0; + for (final compared in plans) { + if (compared.series.maxTimeSeconds > maxTime) { + maxTime = compared.series.maxTimeSeconds; + } + if (compared.series.maxDepth > maxDepth) { + maxDepth = compared.series.maxDepth; + } + } + if (maxTime <= 0) maxTime = 60; + if (maxDepth <= 0) maxDepth = 10; + + return LineChart( + LineChartData( + minX: 0, + maxX: maxTime / 60, + // Inverted-Y depth convention: depth grows downward. + minY: -(maxDepth * 1.1), + maxY: 0, + lineTouchData: const LineTouchData(enabled: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 24, + getTitlesWidget: (value, meta) => + Text('${value.toInt()}′', style: theme.textTheme.labelSmall), + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + getTitlesWidget: (value, meta) => Text( + units.formatDepth(-value, decimals: 0), + style: theme.textTheme.labelSmall, + ), + ), + ), + ), + gridData: const FlGridData(show: true), + borderData: FlBorderData(show: false), + lineBarsData: [ + for (final (index, compared) in plans.indexed) + LineChartBarData( + spots: [ + for (final point in compared.series.profile) + FlSpot(point.timeSeconds / 60, -point.depth), + ], + color: colors[index % colors.length], + barWidth: 2, + dotData: const FlDotData(show: false), + ), + ], + ), + ); + } + + Widget _diffTable(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + String minutes(int seconds) => '${(seconds / 60).ceil()}′'; + + final rows = <(String, List)>[ + ( + l10n.diveLog_detail_stat_maxDepth, + [for (final p in plans) units.formatDepth(p.outcome.maxDepth)], + ), + ( + l10n.divePlanner_label_runtime, + [for (final p in plans) minutes(p.outcome.runtimeSeconds)], + ), + ( + l10n.divePlanner_label_tts, + [for (final p in plans) minutes(p.outcome.ttsAtBottom)], + ), + ( + l10n.divePlanner_label_deco, + [for (final p in plans) minutes(p.outcome.totalDecoSeconds)], + ), + ( + 'CNS', + [for (final p in plans) '${p.outcome.cnsEnd.toStringAsFixed(0)}%'], + ), + ( + l10n.divePlanner_label_gasConsumption, + [ + for (final p in plans) + '${p.outcome.tankUsages.fold(0, (sum, u) => sum + u.litersUsed).toStringAsFixed(0)} L', + ], + ), + ]; + + Widget cell(String text, {bool header = false, Color? color}) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: header ? FontWeight.w600 : null, + color: color, + ), + ), + ), + ); + + return Column( + children: [ + Row( + children: [ + cell('', header: true), + for (final (index, compared) in plans.indexed) + cell( + compared.plan.name, + header: true, + color: colors[index % colors.length], + ), + ], + ), + const Divider(height: 1), + for (final (label, values) in rows) + Row( + children: [ + cell(label, header: true), + for (final value in values) cell(value), + ], + ), + ], + ); + } +} diff --git a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart index f90593e19..de355afe0 100644 --- a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart +++ b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart @@ -23,12 +23,21 @@ Future showSavedPlansSheet(BuildContext context) { ); } -/// Lists saved plans (newest first) with open, duplicate, and delete. -class SavedPlansSheet extends ConsumerWidget { +/// Lists saved plans (newest first) with open, duplicate, share, delete, +/// import, and a multi-select compare mode. +class SavedPlansSheet extends ConsumerStatefulWidget { const SavedPlansSheet({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SavedPlansSheetState(); +} + +class _SavedPlansSheetState extends ConsumerState { + bool _selecting = false; + final _selected = {}; + + @override + Widget build(BuildContext context) { final summaries = ref.watch(divePlanSummariesProvider); final theme = Theme.of(context); final units = UnitFormatter(ref.watch(settingsProvider)); @@ -51,6 +60,22 @@ class SavedPlansSheet extends ConsumerWidget { style: theme.textTheme.titleMedium, ), ), + if ((plans?.length ?? 0) >= 2) + TextButton.icon( + icon: Icon( + _selecting ? Icons.close : Icons.compare_arrows, + size: 18, + ), + label: Text( + _selecting + ? context.l10n.common_action_cancel + : context.l10n.plannerCanvas_compare_action, + ), + onPressed: () => setState(() { + _selecting = !_selecting; + _selected.clear(); + }), + ), TextButton.icon( icon: const Icon(Icons.file_open, size: 18), label: Text(context.l10n.plannerCanvas_share_import), @@ -79,8 +104,41 @@ class SavedPlansSheet extends ConsumerWidget { child: ListView.builder( shrinkWrap: true, itemCount: plans.length, - itemBuilder: (context, i) => - _PlanTile(summary: plans[i], units: units), + itemBuilder: (context, i) => _selecting + ? CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(plans[i].name), + value: _selected.contains(plans[i].id), + onChanged: (checked) => setState(() { + if (checked ?? false) { + if (_selected.length < 3) { + _selected.add(plans[i].id); + } + } else { + _selected.remove(plans[i].id); + } + }), + ) + : _PlanTile(summary: plans[i], units: units), + ), + ), + if (_selecting) + Padding( + padding: const EdgeInsets.only(top: 8), + child: FilledButton( + onPressed: _selected.length >= 2 + ? () { + final ids = _selected.join(','); + Navigator.of(context).pop(); + GoRouter.of( + context, + ).go('/planning/dive-planner/compare?ids=$ids'); + } + : null, + child: Text( + '${context.l10n.plannerCanvas_compare_action}' + ' (${_selected.length})', + ), ), ), ], diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a1e9942ca..999631d0f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5677,6 +5677,9 @@ "count": { "type": "int" } } }, + "plannerCanvas_compare_action": "Compare", + "plannerCanvas_compare_needTwo": "Select at least two plans to compare", + "plannerCanvas_compare_title": "Compare plans", "plannerCanvas_contingency_base": "Base", "plannerCanvas_contingency_depthDelta": "Extra depth", "plannerCanvas_contingency_lostGas": "Lost {gas}", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 283e9441a..b0670040e 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17720,6 +17720,24 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 issue} other{{count} issues}}'** String plannerCanvas_chip_issues(int count); + /// No description provided for @plannerCanvas_compare_action. + /// + /// In en, this message translates to: + /// **'Compare'** + String get plannerCanvas_compare_action; + + /// No description provided for @plannerCanvas_compare_needTwo. + /// + /// In en, this message translates to: + /// **'Select at least two plans to compare'** + String get plannerCanvas_compare_needTwo; + + /// No description provided for @plannerCanvas_compare_title. + /// + /// In en, this message translates to: + /// **'Compare plans'** + String get plannerCanvas_compare_title; + /// No description provided for @plannerCanvas_contingency_base. /// /// 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 bfc21e1bc..a12fc889e 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10215,6 +10215,16 @@ class AppLocalizationsAr extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'أساسي'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 7a5b6d657..24af39020 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10409,6 +10409,16 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Basis'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index d7812f49b..cc1e31cc9 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10248,6 +10248,16 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Base'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 4d12bc4dc..a72953138 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10398,6 +10398,16 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Base'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index b2bc77bf8..123572341 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10448,6 +10448,16 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Base'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index edd544ee4..413d0be23 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10148,6 +10148,16 @@ class AppLocalizationsHe extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'בסיס'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 6db2d101c..5a3f69808 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10385,6 +10385,16 @@ class AppLocalizationsHu extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Alap'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 31e5179f3..fab6c82c5 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10413,6 +10413,16 @@ class AppLocalizationsIt extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Base'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 7a7eedaf6..0f2aa9d9a 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10336,6 +10336,16 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Basis'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 4333429ca..6bf5b5b9a 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10419,6 +10419,16 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => 'Base'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 91a608aef..962dad359 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9962,6 +9962,16 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Compare'; + + @override + String get plannerCanvas_compare_needTwo => + 'Select at least two plans to compare'; + + @override + String get plannerCanvas_compare_title => 'Compare plans'; + @override String get plannerCanvas_contingency_base => '基准'; diff --git a/test/features/planner/plan_compare_test.dart b/test/features/planner/plan_compare_test.dart new file mode 100644 index 000000000..7ead9d5ff --- /dev/null +++ b/test/features/planner/plan_compare_test.dart @@ -0,0 +1,92 @@ +import 'package:fl_chart/fl_chart.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/planner/presentation/pages/plan_compare_page.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(); + }); + + testWidgets('compare page overlays profiles and lists diff rows', ( + tester, + ) async { + // Seed two saved plans through the notifier + repository. + final seedContainer = ProviderContainer( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + ); + addTearDown(seedContainer.dispose); + final notifier = seedContainer.read(divePlanNotifierProvider.notifier); + + notifier.addSimplePlan(maxDepth: 30, bottomTimeMinutes: 20); + notifier.updateName('Shallow plan'); + final firstId = seedContainer.read(divePlanNotifierProvider).id; + await notifier.save(); + + notifier.newPlan(); + notifier.addSimplePlan(maxDepth: 45, bottomTimeMinutes: 25); + notifier.updateName('Deep plan'); + final secondId = seedContainer.read(divePlanNotifierProvider).id; + await notifier.save(); + + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: PlanComparePage(planIds: [firstId, secondId]), + ), + ); + await tester.pumpAndSettle(); + + // One profile line per plan. + final chart = tester.widget(find.byType(LineChart)); + expect(chart.data.lineBarsData, hasLength(2)); + + // Legend and diff-table header carry both plan names (legend + header). + expect(find.text('Shallow plan'), findsNWidgets(2)); + expect(find.text('Deep plan'), findsNWidgets(2)); + expect(find.text('TTS'), findsOneWidget); + expect(find.text('CNS'), findsOneWidget); + }); + + testWidgets('fewer than two resolvable plans shows the empty state', ( + tester, + ) async { + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + child: const PlanComparePage(planIds: ['missing-1', 'missing-2']), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('at least two'), findsOneWidget); + }); +} From d60c992a3ed1c626d624806f3ce609afe1397ed5 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:33:58 -0400 Subject: [PATCH 7/9] feat(planning): hub leads with the planner; deco calculator gains altitude/salinity --- .../pages/deco_calculator_page.dart | 3 + .../providers/deco_calculator_providers.dart | 28 +++- .../widgets/environment_inputs.dart | 69 ++++++++++ .../presentation/pages/planning_page.dart | 126 +++++++++++++++--- lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_localizations.dart | 24 ++++ lib/l10n/arb/app_localizations_ar.dart | 12 ++ lib/l10n/arb/app_localizations_de.dart | 12 ++ lib/l10n/arb/app_localizations_en.dart | 12 ++ lib/l10n/arb/app_localizations_es.dart | 12 ++ lib/l10n/arb/app_localizations_fr.dart | 12 ++ lib/l10n/arb/app_localizations_he.dart | 12 ++ lib/l10n/arb/app_localizations_hu.dart | 12 ++ lib/l10n/arb/app_localizations_it.dart | 12 ++ lib/l10n/arb/app_localizations_nl.dart | 12 ++ lib/l10n/arb/app_localizations_pt.dart | 12 ++ lib/l10n/arb/app_localizations_zh.dart | 12 ++ .../features/planning/planning_page_test.dart | 81 +++++++++++ 18 files changed, 450 insertions(+), 17 deletions(-) create mode 100644 lib/features/deco_calculator/presentation/widgets/environment_inputs.dart create mode 100644 test/features/planning/planning_page_test.dart diff --git a/lib/features/deco_calculator/presentation/pages/deco_calculator_page.dart b/lib/features/deco_calculator/presentation/pages/deco_calculator_page.dart index a8fbeedcb..658812db7 100644 --- a/lib/features/deco_calculator/presentation/pages/deco_calculator_page.dart +++ b/lib/features/deco_calculator/presentation/pages/deco_calculator_page.dart @@ -10,6 +10,7 @@ import 'package:submersion/features/dive_planner/presentation/providers/dive_pla import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/deco_calculator/presentation/providers/deco_calculator_providers.dart'; import 'package:submersion/features/deco_calculator/presentation/widgets/depth_slider.dart'; +import 'package:submersion/features/deco_calculator/presentation/widgets/environment_inputs.dart'; import 'package:submersion/features/deco_calculator/presentation/widgets/gas_mix_selector.dart'; import 'package:submersion/features/deco_calculator/presentation/widgets/gas_warnings_display.dart'; import 'package:submersion/features/deco_calculator/presentation/widgets/time_slider.dart'; @@ -68,6 +69,8 @@ class DecoCalculatorPage extends ConsumerWidget { const TimeSlider(), const SizedBox(height: 16), const GasMixSelector(), + const SizedBox(height: 16), + const EnvironmentInputs(), ], ), ), diff --git a/lib/features/deco_calculator/presentation/providers/deco_calculator_providers.dart b/lib/features/deco_calculator/presentation/providers/deco_calculator_providers.dart index 9829b545f..282e735e1 100644 --- a/lib/features/deco_calculator/presentation/providers/deco_calculator_providers.dart +++ b/lib/features/deco_calculator/presentation/providers/deco_calculator_providers.dart @@ -1,7 +1,9 @@ import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/deco/buhlmann_algorithm.dart'; import 'package:submersion/core/deco/entities/deco_status.dart'; +import 'package:submersion/core/deco/entities/dive_environment.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -11,6 +13,26 @@ final calcDepthProvider = StateProvider((ref) => 18.0); /// Input state: bottom time in minutes final calcTimeProvider = StateProvider((ref) => 30); +/// Input state: altitude above sea level in meters (null = sea level) +final calcAltitudeProvider = StateProvider((ref) => null); + +/// Input state: water type (null = legacy standard water) +final calcWaterTypeProvider = StateProvider((ref) => null); + +/// The dive environment (altitude + salinity) shared with the planner's +/// engine so the calculator agrees with the planner at altitude. +final calcEnvironmentProvider = Provider((ref) { + final altitude = ref.watch(calcAltitudeProvider); + final waterType = ref.watch(calcWaterTypeProvider); + if (altitude == null && waterType == null) { + return DiveEnvironment.standard; + } + return DiveEnvironment.forConditions( + altitudeMeters: altitude, + waterType: waterType, + ); +}); + /// Input state: O2 percentage (21-100) final calcO2Provider = StateProvider((ref) => 21.0); @@ -70,10 +92,12 @@ final calcDecoStatusProvider = Provider((ref) { final fHe = ref.watch(calcHeFractionProvider); final settings = ref.watch(settingsProvider); - // Create algorithm with user's GF settings + // Create algorithm with user's GF settings and the configured + // altitude/salinity environment (same seam the planner engine uses). final algorithm = BuhlmannAlgorithm( gfLow: settings.gfLowDecimal, gfHigh: settings.gfHighDecimal, + environment: ref.watch(calcEnvironmentProvider), ); // Simulate the dive segment at depth for the given time @@ -124,6 +148,8 @@ void resetCalculator(WidgetRef ref) { ref.read(calcTimeProvider.notifier).state = 30; ref.read(calcO2Provider.notifier).state = 21.0; ref.read(calcHeProvider.notifier).state = 0.0; + ref.read(calcAltitudeProvider.notifier).state = null; + ref.read(calcWaterTypeProvider.notifier).state = null; } /// Apply a gas preset diff --git a/lib/features/deco_calculator/presentation/widgets/environment_inputs.dart b/lib/features/deco_calculator/presentation/widgets/environment_inputs.dart new file mode 100644 index 000000000..80fe4c375 --- /dev/null +++ b/lib/features/deco_calculator/presentation/widgets/environment_inputs.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/deco_calculator/presentation/providers/deco_calculator_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Altitude + water type inputs feeding the calculator's DiveEnvironment +/// (the same altitude/salinity seam the planner engine uses). +class EnvironmentInputs extends ConsumerWidget { + const EnvironmentInputs({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final units = UnitFormatter(ref.watch(settingsProvider)); + final altitude = ref.watch(calcAltitudeProvider); + final waterType = ref.watch(calcWaterTypeProvider); + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextFormField( + initialValue: altitude != null + ? units.convertAltitude(altitude).toStringAsFixed(0) + : '', + decoration: InputDecoration( + labelText: + '${context.l10n.divePlanner_label_altitude} ' + '(${units.altitudeSymbol})', + isDense: true, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (text) { + final parsed = double.tryParse(text); + ref.read(calcAltitudeProvider.notifier).state = parsed == null + ? null + : units.altitudeToMeters(parsed); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + initialValue: waterType, + decoration: InputDecoration( + labelText: context.l10n.decoCalculator_waterType, + isDense: true, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(context.l10n.decoCalculator_waterType_standard), + ), + for (final type in WaterType.values) + DropdownMenuItem(value: type, child: Text(type.displayName)), + ], + onChanged: (type) => + ref.read(calcWaterTypeProvider.notifier).state = type, + ), + ), + ], + ); + } +} diff --git a/lib/features/planning/presentation/pages/planning_page.dart b/lib/features/planning/presentation/pages/planning_page.dart index b11090e6e..4f847bdc8 100644 --- a/lib/features/planning/presentation/pages/planning_page.dart +++ b/lib/features/planning/presentation/pages/planning_page.dart @@ -2,20 +2,20 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/accessibility/semantic_helpers.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart'; +import 'package:submersion/features/planner/presentation/providers/plan_repository_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; -/// Planning hub page displaying all dive planning tools. -/// -/// Provides easy navigation to: -/// - Dive Planner: Create multi-level dive plans with gas switching -/// - Deco Calculator: Calculate NDL, deco stops, CNS/OTU exposure -/// - Gas Calculators: MOD, Best Mix, Consumption, Rock Bottom -/// - Weight Calculator: Recommended dive weight based on equipment -class PlanningPage extends StatelessWidget { +/// Planning hub page: the planner (new plan + recent saved plans) front and +/// center, calculators as a tools list below. +class PlanningPage extends ConsumerWidget { const PlanningPage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; final tools = _planningToolsOf(context); @@ -25,6 +25,9 @@ class PlanningPage extends StatelessWidget { body: ListView( children: [ const SizedBox(height: 8), + const _PlannerSection(), + const SizedBox(height: 8), + _SectionLabel(context.l10n.planning_section_tools), ...List.generate(tools.length * 2 - 1, (index) { if (index.isOdd) return const Divider(height: 1); return _PlanningTile(tool: tools[index ~/ 2]); @@ -66,6 +69,104 @@ class PlanningPage extends StatelessWidget { } } +/// Section header matching the tools/plans grouping. +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + text.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.outline, + letterSpacing: 0.6, + ), + ), + ); + } +} + +/// The planner front and center: a New Plan call to action plus the three +/// most recently touched saved plans. +class _PlannerSection extends ConsumerWidget { + const _PlannerSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final summaries = ref.watch(divePlanSummariesProvider).valueOrNull; + final units = UnitFormatter(ref.watch(settingsProvider)); + final recent = summaries?.take(3).toList() ?? const []; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ExcludeSemantics( + child: Icon( + Icons.edit_calendar, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.planning_card_divePlanner_title, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + FilledButton.icon( + icon: const Icon(Icons.add, size: 18), + label: Text(context.l10n.planning_newPlan), + onPressed: () { + ref.read(divePlanNotifierProvider.notifier).newPlan(); + context.go('/planning/dive-planner'); + }, + ), + ], + ), + if (recent.isNotEmpty) ...[ + const SizedBox(height: 8), + for (final summary in recent) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: const Icon(Icons.route, size: 20), + title: Text(summary.name), + subtitle: Text( + [ + if (summary.maxDepth != null) + units.formatDepth(summary.maxDepth!), + if (summary.runtimeSeconds != null) + '${(summary.runtimeSeconds! / 60).ceil()}′', + summary.mode.name.toUpperCase(), + ].join(' · '), + ), + onTap: () => + context.go('/planning/dive-planner/${summary.id}'), + ), + ], + ], + ), + ), + ), + ); + } +} + /// Data class for a planning tool entry. class _PlanningTool { final IconData icon; @@ -88,13 +189,6 @@ List<_PlanningTool> _planningToolsOf(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return [ - _PlanningTool( - icon: Icons.edit_calendar, - color: colorScheme.primary, - title: context.l10n.planning_card_divePlanner_title, - subtitle: context.l10n.planning_card_divePlanner_subtitle, - route: '/planning/dive-planner', - ), _PlanningTool( icon: Icons.calculate, color: colorScheme.secondary, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 999631d0f..9ff43d168 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1447,6 +1447,8 @@ "decoCalculator_showCustomMixSemantics": "Show custom gas mix selector", "decoCalculator_timeValueMin": "{time} min", "decoCalculator_title": "Deco Calculator", + "decoCalculator_waterType": "Water type", + "decoCalculator_waterType_standard": "Standard", "diveCenters_accessibility_markerLabel": "Dive center: {name}", "diveCenters_accessibility_selected": "selected", "diveCenters_accessibility_viewDetails": "View details for {name}", @@ -5812,6 +5814,8 @@ "planning_card_weightCalculator_subtitle": "Recommended weight for your setup", "planning_card_weightCalculator_title": "Weight Calculator", "planning_info_disclaimer": "These tools are for planning purposes only. Always verify calculations and follow your dive training.", + "planning_newPlan": "New plan", + "planning_section_tools": "Tools", "planning_sidebar_appBar_title": "Planning", "planning_sidebar_decoCalculator_subtitle": "NDL & deco stops", "planning_sidebar_decoCalculator_title": "Deco Calculator", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index b0670040e..96e0bcead 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -4157,6 +4157,18 @@ abstract class AppLocalizations { /// **'Deco Calculator'** String get decoCalculator_title; + /// No description provided for @decoCalculator_waterType. + /// + /// In en, this message translates to: + /// **'Water type'** + String get decoCalculator_waterType; + + /// No description provided for @decoCalculator_waterType_standard. + /// + /// In en, this message translates to: + /// **'Standard'** + String get decoCalculator_waterType_standard; + /// No description provided for @diveCenters_accessibility_markerLabel. /// /// In en, this message translates to: @@ -18128,6 +18140,18 @@ abstract class AppLocalizations { /// **'These tools are for planning purposes only. Always verify calculations and follow your dive training.'** String get planning_info_disclaimer; + /// No description provided for @planning_newPlan. + /// + /// In en, this message translates to: + /// **'New plan'** + String get planning_newPlan; + + /// No description provided for @planning_section_tools. + /// + /// In en, this message translates to: + /// **'Tools'** + String get planning_section_tools; + /// No description provided for @planning_sidebar_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 a12fc889e..675c86e12 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -2349,6 +2349,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get decoCalculator_title => 'حاسبة تخفيف الضغط'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'مركز غوص: $name'; @@ -10461,6 +10467,12 @@ class AppLocalizationsAr extends AppLocalizations { String get planning_info_disclaimer => 'هذه الأدوات لأغراض التخطيط فقط. تحقق دائمًا من الحسابات واتبع تدريبك على الغوص.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'التخطيط'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 24af39020..98b9b0176 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -2415,6 +2415,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get decoCalculator_title => 'Deko-Rechner'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Tauchcenter: $name'; @@ -10655,6 +10661,12 @@ class AppLocalizationsDe extends AppLocalizations { String get planning_info_disclaimer => 'Diese Werkzeuge dienen nur der Planung. Überprüfen Sie Berechnungen immer und befolgen Sie Ihre Tauchausbildung.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Planung'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index cc1e31cc9..d2c94d032 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -2361,6 +2361,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get decoCalculator_title => 'Deco Calculator'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Dive center: $name'; @@ -10494,6 +10500,12 @@ class AppLocalizationsEn extends AppLocalizations { String get planning_info_disclaimer => 'These tools are for planning purposes only. Always verify calculations and follow your dive training.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Planning'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index a72953138..bf2d7858d 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -2408,6 +2408,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get decoCalculator_title => 'Calculadora de Descompresión'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centro de buceo: $name'; @@ -10646,6 +10652,12 @@ class AppLocalizationsEs extends AppLocalizations { String get planning_info_disclaimer => 'Estas herramientas son solo para fines de planificacion. Siempre verifica los calculos y sigue tu formacion de buceo.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Planificacion'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 123572341..ac009ed86 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -2417,6 +2417,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get decoCalculator_title => 'Calculateur de décompression'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centre de plongée : $name'; @@ -10696,6 +10702,12 @@ class AppLocalizationsFr extends AppLocalizations { String get planning_info_disclaimer => 'Ces outils sont destines a la planification uniquement. Verifiez toujours les calculs et suivez votre formation de plongee.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Planification'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 413d0be23..fc034b266 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -2333,6 +2333,12 @@ class AppLocalizationsHe extends AppLocalizations { @override String get decoCalculator_title => 'מחשבון דקומפרסיה'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'מרכז צלילה: $name'; @@ -10393,6 +10399,12 @@ class AppLocalizationsHe extends AppLocalizations { String get planning_info_disclaimer => 'כלים אלה מיועדים למטרות תכנון בלבד. תמיד אמת חישובים ופעל לפי הכשרת הצלילה שלך.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'תכנון'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 5a3f69808..60fd240e8 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -2394,6 +2394,12 @@ class AppLocalizationsHu extends AppLocalizations { @override String get decoCalculator_title => 'Dekompressziós kalkulátor'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Búvárközpont: $name'; @@ -10631,6 +10637,12 @@ class AppLocalizationsHu extends AppLocalizations { String get planning_info_disclaimer => 'Ezek az eszkozök kizarolag tervezesi celokat szolgalnak. Mindig ellenorizze a szamitasokat es kövesse merülesi kepzesenek iranyelveit.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Tervezes'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index fab6c82c5..595a3f074 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -2410,6 +2410,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get decoCalculator_title => 'Calcolatore Deco'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centro immersioni: $name'; @@ -10661,6 +10667,12 @@ class AppLocalizationsIt extends AppLocalizations { String get planning_info_disclaimer => 'Questi strumenti sono solo per la pianificazione. Verifica sempre i calcoli e segui la tua formazione subacquea.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Pianificazione'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 0f2aa9d9a..c7a932c54 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -2389,6 +2389,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get decoCalculator_title => 'Deco calculator'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Duikcentrum: $name'; @@ -10582,6 +10588,12 @@ class AppLocalizationsNl extends AppLocalizations { String get planning_info_disclaimer => 'Deze tools zijn alleen voor planningsdoeleinden. Controleer berekeningen altijd en volg je duikopleiding.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Planning'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 6bf5b5b9a..12d60b261 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -2408,6 +2408,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get decoCalculator_title => 'Calculadora de Descompressão'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centro de mergulho: $name'; @@ -10667,6 +10673,12 @@ class AppLocalizationsPt extends AppLocalizations { String get planning_info_disclaimer => 'Estas ferramentas sao apenas para fins de planejamento. Sempre verifique os calculos e siga seu treinamento de mergulho.'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => 'Planejamento'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 962dad359..bb6a7eed0 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -2287,6 +2287,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get decoCalculator_title => '减压计算器'; + @override + String get decoCalculator_waterType => 'Water type'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return '潜水中心:$name'; @@ -10199,6 +10205,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get planning_info_disclaimer => '这些工具仅供计划参考。请务必验证计算结果并遵循您的潜水训练。'; + @override + String get planning_newPlan => 'New plan'; + + @override + String get planning_section_tools => 'Tools'; + @override String get planning_sidebar_appBar_title => '计划'; diff --git a/test/features/planning/planning_page_test.dart b/test/features/planning/planning_page_test.dart new file mode 100644 index 000000000..732a0041a --- /dev/null +++ b/test/features/planning/planning_page_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/map_style.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/deco_calculator/presentation/providers/deco_calculator_providers.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/planning/presentation/pages/planning_page.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() { + testWidgets('hub leads with New plan and recent saved plans', (tester) async { + final summaries = [ + DivePlanSummary( + id: 'p1', + name: 'Reef 30m', + updatedAt: DateTime(2026, 7, 4), + maxDepth: 30.0, + runtimeSeconds: 45 * 60, + ttsSeconds: 300, + mode: PlanMode.oc, + ), + DivePlanSummary( + id: 'p2', + name: 'Wreck 50m', + updatedAt: DateTime(2026, 7, 3), + maxDepth: 50.0, + runtimeSeconds: 80 * 60, + ttsSeconds: 2400, + mode: PlanMode.ccr, + ), + ]; + + await tester.pumpWidget( + testApp( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + divePlanSummariesProvider.overrideWith((ref) async => summaries), + ], + child: const PlanningPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('New plan'), findsOneWidget); + expect(find.text('Reef 30m'), findsOneWidget); + expect(find.text('Wreck 50m'), findsOneWidget); + expect(find.text('TOOLS'), findsOneWidget); + // The calculators remain as tools. + expect(find.text('Deco Calculator'), findsOneWidget); + }); + + test('deco calculator environment defaults to legacy standard water', () { + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + ); + addTearDown(container.dispose); + + final ndlAtSea = container.read(calcDecoStatusProvider).ndlSeconds; + + // Altitude shortens the NDL at the same depth/time/gas. + container.read(calcAltitudeProvider.notifier).state = 2500.0; + final ndlAtAltitude = container.read(calcDecoStatusProvider).ndlSeconds; + expect(ndlAtAltitude, lessThan(ndlAtSea)); + }); +} From 4535dba933db2101e51abb4ffca411eee195121a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 15:36:40 -0400 Subject: [PATCH 8/9] chore(l10n): translate phase 7 planner strings into all locales --- lib/l10n/arb/app_ar.arb | 16 +++++++++ lib/l10n/arb/app_de.arb | 16 +++++++++ lib/l10n/arb/app_es.arb | 16 +++++++++ lib/l10n/arb/app_fr.arb | 16 +++++++++ lib/l10n/arb/app_he.arb | 16 +++++++++ lib/l10n/arb/app_hu.arb | 16 +++++++++ lib/l10n/arb/app_it.arb | 16 +++++++++ lib/l10n/arb/app_localizations_ar.dart | 33 +++++++++--------- lib/l10n/arb/app_localizations_de.dart | 30 ++++++++-------- lib/l10n/arb/app_localizations_es.dart | 30 ++++++++-------- lib/l10n/arb/app_localizations_fr.dart | 28 +++++++-------- lib/l10n/arb/app_localizations_he.dart | 33 +++++++++--------- lib/l10n/arb/app_localizations_hu.dart | 32 ++++++++--------- lib/l10n/arb/app_localizations_it.dart | 28 +++++++-------- lib/l10n/arb/app_localizations_nl.dart | 32 ++++++++--------- lib/l10n/arb/app_localizations_pt.dart | 30 ++++++++-------- lib/l10n/arb/app_localizations_zh.dart | 34 +++++++++---------- lib/l10n/arb/app_nl.arb | 16 +++++++++ lib/l10n/arb/app_pt.arb | 16 +++++++++ lib/l10n/arb/app_zh.arb | 16 +++++++++ .../features/planner/plan_slate_pdf_test.dart | 2 +- 21 files changed, 314 insertions(+), 158 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 7d84e5282..46c72e42a 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "إظهار محدد خليط الغاز المخصص", "decoCalculator_timeValueMin": "{time} دقيقة", "decoCalculator_title": "حاسبة تخفيف الضغط", + "decoCalculator_waterType": "نوع الماء", + "decoCalculator_waterType_standard": "قياسي", "diveCenters_accessibility_markerLabel": "مركز غوص: {name}", "diveCenters_accessibility_selected": "محدد", "diveCenters_accessibility_viewDetails": "عرض تفاصيل {name}", @@ -3246,6 +3248,9 @@ "plannerCanvas_ccr_switchDepth": "عمق تبديل نقطة الضبط", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{مشكلة واحدة} other{{count} مشكلات}}", + "plannerCanvas_compare_action": "مقارنة", + "plannerCanvas_compare_needTwo": "اختر خطتين على الأقل للمقارنة", + "plannerCanvas_compare_title": "مقارنة الخطط", "plannerCanvas_contingency_base": "أساسي", "plannerCanvas_contingency_depthDelta": "عمق إضافي", "plannerCanvas_contingency_lostGas": "فقدان {gas}", @@ -3267,6 +3272,9 @@ "plannerCanvas_issue_minGas": "تنتهي الأسطوانة تحت الحد الأدنى الآمن {pressure}", "plannerCanvas_issue_noBailout": "خطة تخفيف الضغط CCR لا تتضمن غاز إنقاذ (bailout)", "plannerCanvas_issue_noDecoGas": "يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف", + "plannerCanvas_range_base": "أساسي", + "plannerCanvas_range_legend": "تعرض الخلايا زمن الصعود إلى السطح؛ الأحمر = غير قابلة للغطس كما هو مخطط", + "plannerCanvas_range_title": "جدول النطاقات", "plannerCanvas_results_noDeco": "لا يلزم تخفيف الضغط", "plannerCanvas_saved_deleteConfirmBody": "حذف \"{name}\" نهائيًا؟", "plannerCanvas_saved_deleteConfirmTitle": "حذف الخطة؟", @@ -3276,6 +3284,12 @@ "plannerCanvas_saved_title": "الخطط المحفوظة", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "استيراد", + "plannerCanvas_share_importFailed": "تعذر استيراد الخطة: {reason}", + "plannerCanvas_share_menu": "مشاركة ملف الخطة", + "plannerCanvas_slate_menu": "تصدير اللوح (PDF)", + "plannerCanvas_slate_minGas": "الحد الأدنى من الغاز", + "plannerCanvas_slate_turn": "العودة", "plannerCanvas_table_depth": "العمق", "plannerCanvas_table_gas": "الغاز", "plannerCanvas_table_runtime": "RT", @@ -3302,6 +3316,8 @@ "planning_card_weightCalculator_subtitle": "الوزن الموصى به لإعدادك", "planning_card_weightCalculator_title": "حاسبة الأوزان", "planning_info_disclaimer": "هذه الأدوات لأغراض التخطيط فقط. تحقق دائمًا من الحسابات واتبع تدريبك على الغوص.", + "planning_newPlan": "خطة جديدة", + "planning_section_tools": "أدوات", "planning_sidebar_appBar_title": "التخطيط", "planning_sidebar_decoCalculator_subtitle": "NDL ومحطات تخفيف الضغط", "planning_sidebar_decoCalculator_title": "حاسبة تخفيف الضغط", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 97e4c0a3b..b566cc293 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "Benutzerdefinierte Gasgemischauswahl anzeigen", "decoCalculator_timeValueMin": "{time} min", "decoCalculator_title": "Deko-Rechner", + "decoCalculator_waterType": "Wassertyp", + "decoCalculator_waterType_standard": "Standard", "diveCenters_accessibility_markerLabel": "Tauchcenter: {name}", "diveCenters_accessibility_selected": "ausgewählt", "diveCenters_accessibility_viewDetails": "Details für {name} anzeigen", @@ -3246,6 +3248,9 @@ "plannerCanvas_ccr_switchDepth": "Setpoint-Wechseltiefe", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 Problem} other{{count} Probleme}}", + "plannerCanvas_compare_action": "Vergleichen", + "plannerCanvas_compare_needTwo": "Wähle mindestens zwei Pläne zum Vergleichen", + "plannerCanvas_compare_title": "Pläne vergleichen", "plannerCanvas_contingency_base": "Basis", "plannerCanvas_contingency_depthDelta": "Zusatztiefe", "plannerCanvas_contingency_lostGas": "{gas} verloren", @@ -3267,6 +3272,9 @@ "plannerCanvas_issue_minGas": "Flasche endet unter dem Rock-Bottom-Minimum von {pressure}", "plannerCanvas_issue_noBailout": "CCR-Dekompressionsplan führt kein Bailout-Gas mit", "plannerCanvas_issue_noDecoGas": "Dekompression erforderlich, aber kein Deko-Gas mitgeführt", + "plannerCanvas_range_base": "Basis", + "plannerCanvas_range_legend": "Zellen zeigen die Zeit bis zur Oberfläche; rot = nicht wie geplant tauchbar", + "plannerCanvas_range_title": "Bereichstabelle", "plannerCanvas_results_noDeco": "Keine Dekompression erforderlich", "plannerCanvas_saved_deleteConfirmBody": "„{name}“ endgültig löschen?", "plannerCanvas_saved_deleteConfirmTitle": "Plan löschen?", @@ -3276,6 +3284,12 @@ "plannerCanvas_saved_title": "Gespeicherte Pläne", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "Importieren", + "plannerCanvas_share_importFailed": "Plan konnte nicht importiert werden: {reason}", + "plannerCanvas_share_menu": "Plandatei teilen", + "plannerCanvas_slate_menu": "Slate exportieren (PDF)", + "plannerCanvas_slate_minGas": "Min-Gas", + "plannerCanvas_slate_turn": "Umkehr", "plannerCanvas_table_depth": "Tiefe", "plannerCanvas_table_gas": "Gas", "plannerCanvas_table_runtime": "RT", @@ -3302,6 +3316,8 @@ "planning_card_weightCalculator_subtitle": "Empfohlenes Gewicht für Ihre Konfiguration", "planning_card_weightCalculator_title": "Gewichtsrechner", "planning_info_disclaimer": "Diese Werkzeuge dienen nur der Planung. Überprüfen Sie Berechnungen immer und befolgen Sie Ihre Tauchausbildung.", + "planning_newPlan": "Neuer Plan", + "planning_section_tools": "Werkzeuge", "planning_sidebar_appBar_title": "Planung", "planning_sidebar_decoCalculator_subtitle": "NDL & Dekostopps", "planning_sidebar_decoCalculator_title": "Deko-Rechner", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 8f6ce68c6..aa69c4358 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "Mostrar selector de mezcla de gas personalizada", "decoCalculator_timeValueMin": "{time} min", "decoCalculator_title": "Calculadora de Descompresión", + "decoCalculator_waterType": "Tipo de agua", + "decoCalculator_waterType_standard": "Estándar", "diveCenters_accessibility_markerLabel": "Centro de buceo: {name}", "diveCenters_accessibility_selected": "seleccionado", "diveCenters_accessibility_viewDetails": "Ver detalles de {name}", @@ -3246,6 +3248,9 @@ "plannerCanvas_ccr_switchDepth": "Profundidad de cambio de setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problema} other{{count} problemas}}", + "plannerCanvas_compare_action": "Comparar", + "plannerCanvas_compare_needTwo": "Selecciona al menos dos planes para comparar", + "plannerCanvas_compare_title": "Comparar planes", "plannerCanvas_contingency_base": "Base", "plannerCanvas_contingency_depthDelta": "Profundidad extra", "plannerCanvas_contingency_lostGas": "{gas} perdido", @@ -3267,6 +3272,9 @@ "plannerCanvas_issue_minGas": "La botella termina por debajo del mínimo rock bottom de {pressure}", "plannerCanvas_issue_noBailout": "El plan de descompresión CCR no lleva gas de bailout", "plannerCanvas_issue_noDecoGas": "Se requiere descompresión pero no se lleva gas de deco", + "plannerCanvas_range_base": "Base", + "plannerCanvas_range_legend": "Las celdas muestran el tiempo hasta la superficie; rojo = no buceable según lo planeado", + "plannerCanvas_range_title": "Tabla de rangos", "plannerCanvas_results_noDeco": "No se requiere descompresión", "plannerCanvas_saved_deleteConfirmBody": "¿Eliminar permanentemente «{name}»?", "plannerCanvas_saved_deleteConfirmTitle": "¿Eliminar plan?", @@ -3276,6 +3284,12 @@ "plannerCanvas_saved_title": "Planes guardados", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "Importar", + "plannerCanvas_share_importFailed": "No se pudo importar el plan: {reason}", + "plannerCanvas_share_menu": "Compartir archivo del plan", + "plannerCanvas_slate_menu": "Exportar pizarra (PDF)", + "plannerCanvas_slate_minGas": "Gas mínimo", + "plannerCanvas_slate_turn": "Retorno", "plannerCanvas_table_depth": "Profundidad", "plannerCanvas_table_gas": "Gas", "plannerCanvas_table_runtime": "RT", @@ -3302,6 +3316,8 @@ "planning_card_weightCalculator_subtitle": "Peso recomendado para tu configuracion", "planning_card_weightCalculator_title": "Calculadora de peso", "planning_info_disclaimer": "Estas herramientas son solo para fines de planificacion. Siempre verifica los calculos y sigue tu formacion de buceo.", + "planning_newPlan": "Nuevo plan", + "planning_section_tools": "Herramientas", "planning_sidebar_appBar_title": "Planificacion", "planning_sidebar_decoCalculator_subtitle": "NDL y paradas de deco", "planning_sidebar_decoCalculator_title": "Calculadora de deco", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 703abde30..d3ab51dd8 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "Afficher le sélecteur de mélange gazeux personnalisé", "decoCalculator_timeValueMin": "{time} min", "decoCalculator_title": "Calculateur de décompression", + "decoCalculator_waterType": "Type d'eau", + "decoCalculator_waterType_standard": "Standard", "diveCenters_accessibility_markerLabel": "Centre de plongée : {name}", "diveCenters_accessibility_selected": "sélectionné", "diveCenters_accessibility_viewDetails": "Afficher les détails de {name}", @@ -3212,6 +3214,9 @@ "plannerCanvas_ccr_switchDepth": "Profondeur de bascule du setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problème} other{{count} problèmes}}", + "plannerCanvas_compare_action": "Comparer", + "plannerCanvas_compare_needTwo": "Sélectionnez au moins deux plans à comparer", + "plannerCanvas_compare_title": "Comparer les plans", "plannerCanvas_contingency_base": "Base", "plannerCanvas_contingency_depthDelta": "Profondeur en plus", "plannerCanvas_contingency_lostGas": "{gas} perdu", @@ -3233,6 +3238,9 @@ "plannerCanvas_issue_minGas": "Le bloc finit sous le minimum rock bottom de {pressure}", "plannerCanvas_issue_noBailout": "Le plan de décompression CCR n'emporte aucun gaz de bailout", "plannerCanvas_issue_noDecoGas": "Décompression requise mais aucun gaz de déco emporté", + "plannerCanvas_range_base": "Base", + "plannerCanvas_range_legend": "Les cellules indiquent le temps de remontée ; rouge = non plongeable comme prévu", + "plannerCanvas_range_title": "Table de variations", "plannerCanvas_results_noDeco": "Aucune décompression requise", "plannerCanvas_saved_deleteConfirmBody": "Supprimer définitivement « {name} » ?", "plannerCanvas_saved_deleteConfirmTitle": "Supprimer le plan ?", @@ -3242,6 +3250,12 @@ "plannerCanvas_saved_title": "Plans enregistrés", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "Importer", + "plannerCanvas_share_importFailed": "Impossible d'importer le plan : {reason}", + "plannerCanvas_share_menu": "Partager le fichier du plan", + "plannerCanvas_slate_menu": "Exporter la plaquette (PDF)", + "plannerCanvas_slate_minGas": "Gaz minimum", + "plannerCanvas_slate_turn": "Demi-tour", "plannerCanvas_table_depth": "Profondeur", "plannerCanvas_table_gas": "Gaz", "plannerCanvas_table_runtime": "RT", @@ -3268,6 +3282,8 @@ "planning_card_weightCalculator_subtitle": "Lestage recommande pour votre configuration", "planning_card_weightCalculator_title": "Calculateur de lestage", "planning_info_disclaimer": "Ces outils sont destines a la planification uniquement. Verifiez toujours les calculs et suivez votre formation de plongee.", + "planning_newPlan": "Nouveau plan", + "planning_section_tools": "Outils", "planning_sidebar_appBar_title": "Planification", "planning_sidebar_decoCalculator_subtitle": "NDL et paliers deco", "planning_sidebar_decoCalculator_title": "Calculateur deco", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 088a31044..60aec1163 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "הצג בורר תערובת גז מותאמת", "decoCalculator_timeValueMin": "{time} דקות", "decoCalculator_title": "מחשבון דקומפרסיה", + "decoCalculator_waterType": "סוג מים", + "decoCalculator_waterType_standard": "רגיל", "diveCenters_accessibility_markerLabel": "מרכז צלילה: {name}", "diveCenters_accessibility_selected": "נבחר", "diveCenters_accessibility_viewDetails": "הצג פרטים עבור {name}", @@ -3212,6 +3214,9 @@ "plannerCanvas_ccr_switchDepth": "עומק החלפת נקודת הכיוון", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{בעיה אחת} other{{count} בעיות}}", + "plannerCanvas_compare_action": "השוואה", + "plannerCanvas_compare_needTwo": "בחר לפחות שתי תוכניות להשוואה", + "plannerCanvas_compare_title": "השוואת תוכניות", "plannerCanvas_contingency_base": "בסיס", "plannerCanvas_contingency_depthDelta": "עומק נוסף", "plannerCanvas_contingency_lostGas": "אבדן {gas}", @@ -3233,6 +3238,9 @@ "plannerCanvas_issue_minGas": "המיכל מסתיים מתחת למינימום rock bottom של {pressure}", "plannerCanvas_issue_noBailout": "תוכנית דקומפרסיה CCR ללא גז חילוץ (bailout)", "plannerCanvas_issue_noDecoGas": "נדרשת דקומפרסיה אך לא נלקח גז דקו", + "plannerCanvas_range_base": "בסיס", + "plannerCanvas_range_legend": "התאים מציגים את זמן העלייה לפני המים; אדום = לא ניתן לצלול כמתוכנן", + "plannerCanvas_range_title": "טבלת טווחים", "plannerCanvas_results_noDeco": "לא נדרשת דקומפרסיה", "plannerCanvas_saved_deleteConfirmBody": "למחוק לצמיתות את \"{name}\"?", "plannerCanvas_saved_deleteConfirmTitle": "למחוק את התוכנית?", @@ -3242,6 +3250,12 @@ "plannerCanvas_saved_title": "תוכניות שמורות", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "ייבוא", + "plannerCanvas_share_importFailed": "לא ניתן לייבא את התוכנית: {reason}", + "plannerCanvas_share_menu": "שיתוף קובץ תוכנית", + "plannerCanvas_slate_menu": "ייצוא לוח (PDF)", + "plannerCanvas_slate_minGas": "גז מינימלי", + "plannerCanvas_slate_turn": "נקודת חזרה", "plannerCanvas_table_depth": "עומק", "plannerCanvas_table_gas": "גז", "plannerCanvas_table_runtime": "RT", @@ -3268,6 +3282,8 @@ "planning_card_weightCalculator_subtitle": "משקל מומלץ להגדרה שלך", "planning_card_weightCalculator_title": "מחשבון משקל", "planning_info_disclaimer": "כלים אלה מיועדים למטרות תכנון בלבד. תמיד אמת חישובים ופעל לפי הכשרת הצלילה שלך.", + "planning_newPlan": "תוכנית חדשה", + "planning_section_tools": "כלים", "planning_sidebar_appBar_title": "תכנון", "planning_sidebar_decoCalculator_subtitle": "NDL ועצירות דקו", "planning_sidebar_decoCalculator_title": "מחשבון דקו", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 57087b267..0bcfea683 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "Egyedi gázkeverék választó megjelenítése", "decoCalculator_timeValueMin": "{time} perc", "decoCalculator_title": "Dekompressziós kalkulátor", + "decoCalculator_waterType": "Víztípus", + "decoCalculator_waterType_standard": "Normál", "diveCenters_accessibility_markerLabel": "Búvárközpont: {name}", "diveCenters_accessibility_selected": "kiválasztva", "diveCenters_accessibility_viewDetails": "Részletek megtekintése: {name}", @@ -3212,6 +3214,9 @@ "plannerCanvas_ccr_switchDepth": "Setpoint váltási mélység", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 probléma} other{{count} probléma}}", + "plannerCanvas_compare_action": "Összehasonlítás", + "plannerCanvas_compare_needTwo": "Válassz ki legalább két tervet az összehasonlításhoz", + "plannerCanvas_compare_title": "Tervek összehasonlítása", "plannerCanvas_contingency_base": "Alap", "plannerCanvas_contingency_depthDelta": "Extra mélység", "plannerCanvas_contingency_lostGas": "{gas} elveszett", @@ -3233,6 +3238,9 @@ "plannerCanvas_issue_minGas": "A palack a rock-bottom minimum ({pressure}) alatt végez", "plannerCanvas_issue_noBailout": "A CCR dekompressziós terv nem tartalmaz bailout gázt", "plannerCanvas_issue_noDecoGas": "Dekompresszió szükséges, de nincs deco gáz", + "plannerCanvas_range_base": "Alap", + "plannerCanvas_range_legend": "A cellák a felszínre érés idejét mutatják; piros = a terv szerint nem merülhető", + "plannerCanvas_range_title": "Tartománytáblázat", "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?", @@ -3242,6 +3250,12 @@ "plannerCanvas_saved_title": "Mentett tervek", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "Importálás", + "plannerCanvas_share_importFailed": "A terv importálása nem sikerült: {reason}", + "plannerCanvas_share_menu": "Tervfájl megosztása", + "plannerCanvas_slate_menu": "Tábla exportálása (PDF)", + "plannerCanvas_slate_minGas": "Minimális gáz", + "plannerCanvas_slate_turn": "Forduló", "plannerCanvas_table_depth": "Mélység", "plannerCanvas_table_gas": "Gáz", "plannerCanvas_table_runtime": "RT", @@ -3268,6 +3282,8 @@ "planning_card_weightCalculator_subtitle": "Ajanlott suly az adott felszereleshez", "planning_card_weightCalculator_title": "Suly kalkulator", "planning_info_disclaimer": "Ezek az eszkozök kizarolag tervezesi celokat szolgalnak. Mindig ellenorizze a szamitasokat es kövesse merülesi kepzesenek iranyelveit.", + "planning_newPlan": "Új terv", + "planning_section_tools": "Eszközök", "planning_sidebar_appBar_title": "Tervezes", "planning_sidebar_decoCalculator_subtitle": "NDL es deko megallok", "planning_sidebar_decoCalculator_title": "Deko kalkulator", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 7493519bf..3371538cc 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "Mostra selettore miscela gas personalizzata", "decoCalculator_timeValueMin": "{time} min", "decoCalculator_title": "Calcolatore Deco", + "decoCalculator_waterType": "Tipo di acqua", + "decoCalculator_waterType_standard": "Standard", "diveCenters_accessibility_markerLabel": "Centro immersioni: {name}", "diveCenters_accessibility_selected": "selezionato", "diveCenters_accessibility_viewDetails": "Visualizza dettagli per {name}", @@ -3212,6 +3214,9 @@ "plannerCanvas_ccr_switchDepth": "Profondità di cambio setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problema} other{{count} problemi}}", + "plannerCanvas_compare_action": "Confronta", + "plannerCanvas_compare_needTwo": "Seleziona almeno due piani da confrontare", + "plannerCanvas_compare_title": "Confronta piani", "plannerCanvas_contingency_base": "Base", "plannerCanvas_contingency_depthDelta": "Profondità extra", "plannerCanvas_contingency_lostGas": "{gas} perso", @@ -3233,6 +3238,9 @@ "plannerCanvas_issue_minGas": "La bombola termina sotto il minimo rock bottom di {pressure}", "plannerCanvas_issue_noBailout": "Il piano di decompressione CCR non prevede gas di bailout", "plannerCanvas_issue_noDecoGas": "Decompressione necessaria ma nessun gas deco a bordo", + "plannerCanvas_range_base": "Base", + "plannerCanvas_range_legend": "Le celle mostrano il tempo di risalita; rosso = non immergibile come pianificato", + "plannerCanvas_range_title": "Tabella di variazione", "plannerCanvas_results_noDeco": "Nessuna decompressione necessaria", "plannerCanvas_saved_deleteConfirmBody": "Eliminare definitivamente «{name}»?", "plannerCanvas_saved_deleteConfirmTitle": "Eliminare il piano?", @@ -3242,6 +3250,12 @@ "plannerCanvas_saved_title": "Piani salvati", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "Importa", + "plannerCanvas_share_importFailed": "Impossibile importare il piano: {reason}", + "plannerCanvas_share_menu": "Condividi file del piano", + "plannerCanvas_slate_menu": "Esporta lavagnetta (PDF)", + "plannerCanvas_slate_minGas": "Gas minimo", + "plannerCanvas_slate_turn": "Rientro", "plannerCanvas_table_depth": "Profondità", "plannerCanvas_table_gas": "Gas", "plannerCanvas_table_runtime": "RT", @@ -3268,6 +3282,8 @@ "planning_card_weightCalculator_subtitle": "Zavorra raccomandata per la tua configurazione", "planning_card_weightCalculator_title": "Calcolatore zavorra", "planning_info_disclaimer": "Questi strumenti sono solo per la pianificazione. Verifica sempre i calcoli e segui la tua formazione subacquea.", + "planning_newPlan": "Nuovo piano", + "planning_section_tools": "Strumenti", "planning_sidebar_appBar_title": "Pianificazione", "planning_sidebar_decoCalculator_subtitle": "NDL e soste deco", "planning_sidebar_decoCalculator_title": "Calcolatore deco", diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 675c86e12..ea631f3c9 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -2350,10 +2350,10 @@ class AppLocalizationsAr extends AppLocalizations { String get decoCalculator_title => 'حاسبة تخفيف الضغط'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'نوع الماء'; @override - String get decoCalculator_waterType_standard => 'Standard'; + String get decoCalculator_waterType_standard => 'قياسي'; @override String diveCenters_accessibility_markerLabel(Object name) { @@ -10222,14 +10222,13 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'مقارنة'; @override - String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + String get plannerCanvas_compare_needTwo => 'اختر خطتين على الأقل للمقارنة'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'مقارنة الخطط'; @override String get plannerCanvas_contingency_base => 'أساسي'; @@ -10314,14 +10313,14 @@ class AppLocalizationsAr extends AppLocalizations { 'يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف'; @override - String get plannerCanvas_range_base => 'Base'; + String get plannerCanvas_range_base => 'أساسي'; @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'تعرض الخلايا زمن الصعود إلى السطح؛ الأحمر = غير قابلة للغطس كما هو مخطط'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'جدول النطاقات'; @override String get plannerCanvas_results_noDeco => 'لا يلزم تخفيف الضغط'; @@ -10359,24 +10358,24 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'استيراد'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'تعذر استيراد الخطة: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'مشاركة ملف الخطة'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'تصدير اللوح (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'الحد الأدنى من الغاز'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'العودة'; @override String get plannerCanvas_table_depth => 'العمق'; @@ -10468,10 +10467,10 @@ class AppLocalizationsAr extends AppLocalizations { 'هذه الأدوات لأغراض التخطيط فقط. تحقق دائمًا من الحسابات واتبع تدريبك على الغوص.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'خطة جديدة'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'أدوات'; @override String get planning_sidebar_appBar_title => 'التخطيط'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 98b9b0176..bbbfc0064 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -2416,7 +2416,7 @@ class AppLocalizationsDe extends AppLocalizations { String get decoCalculator_title => 'Deko-Rechner'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'Wassertyp'; @override String get decoCalculator_waterType_standard => 'Standard'; @@ -10416,14 +10416,14 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'Vergleichen'; @override String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + 'Wähle mindestens zwei Pläne zum Vergleichen'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'Pläne vergleichen'; @override String get plannerCanvas_contingency_base => 'Basis'; @@ -10508,14 +10508,14 @@ class AppLocalizationsDe extends AppLocalizations { 'Dekompression erforderlich, aber kein Deko-Gas mitgeführt'; @override - String get plannerCanvas_range_base => 'Base'; + String get plannerCanvas_range_base => 'Basis'; @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'Zellen zeigen die Zeit bis zur Oberfläche; rot = nicht wie geplant tauchbar'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'Bereichstabelle'; @override String get plannerCanvas_results_noDeco => 'Keine Dekompression erforderlich'; @@ -10553,24 +10553,24 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'Importieren'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'Plan konnte nicht importiert werden: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'Plandatei teilen'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'Slate exportieren (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'Min-Gas'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'Umkehr'; @override String get plannerCanvas_table_depth => 'Tiefe'; @@ -10662,10 +10662,10 @@ class AppLocalizationsDe extends AppLocalizations { 'Diese Werkzeuge dienen nur der Planung. Überprüfen Sie Berechnungen immer und befolgen Sie Ihre Tauchausbildung.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'Neuer Plan'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'Werkzeuge'; @override String get planning_sidebar_appBar_title => 'Planung'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index bf2d7858d..0459ad299 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -2409,10 +2409,10 @@ class AppLocalizationsEs extends AppLocalizations { String get decoCalculator_title => 'Calculadora de Descompresión'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'Tipo de agua'; @override - String get decoCalculator_waterType_standard => 'Standard'; + String get decoCalculator_waterType_standard => 'Estándar'; @override String diveCenters_accessibility_markerLabel(Object name) { @@ -10405,14 +10405,14 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'Comparar'; @override String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + 'Selecciona al menos dos planes para comparar'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'Comparar planes'; @override String get plannerCanvas_contingency_base => 'Base'; @@ -10503,10 +10503,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'Las celdas muestran el tiempo hasta la superficie; rojo = no buceable según lo planeado'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'Tabla de rangos'; @override String get plannerCanvas_results_noDeco => 'No se requiere descompresión'; @@ -10544,24 +10544,24 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'Importar'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'No se pudo importar el plan: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'Compartir archivo del plan'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'Exportar pizarra (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'Gas mínimo'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'Retorno'; @override String get plannerCanvas_table_depth => 'Profundidad'; @@ -10653,10 +10653,10 @@ class AppLocalizationsEs extends AppLocalizations { 'Estas herramientas son solo para fines de planificacion. Siempre verifica los calculos y sigue tu formacion de buceo.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'Nuevo plan'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'Herramientas'; @override String get planning_sidebar_appBar_title => 'Planificacion'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index ac009ed86..59713118d 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -2418,7 +2418,7 @@ class AppLocalizationsFr extends AppLocalizations { String get decoCalculator_title => 'Calculateur de décompression'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'Type d\'eau'; @override String get decoCalculator_waterType_standard => 'Standard'; @@ -10455,14 +10455,14 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'Comparer'; @override String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + 'Sélectionnez au moins deux plans à comparer'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'Comparer les plans'; @override String get plannerCanvas_contingency_base => 'Base'; @@ -10553,10 +10553,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'Les cellules indiquent le temps de remontée ; rouge = non plongeable comme prévu'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'Table de variations'; @override String get plannerCanvas_results_noDeco => 'Aucune décompression requise'; @@ -10594,24 +10594,24 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'Importer'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'Impossible d\'importer le plan : $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'Partager le fichier du plan'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'Exporter la plaquette (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'Gaz minimum'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'Demi-tour'; @override String get plannerCanvas_table_depth => 'Profondeur'; @@ -10703,10 +10703,10 @@ class AppLocalizationsFr extends AppLocalizations { 'Ces outils sont destines a la planification uniquement. Verifiez toujours les calculs et suivez votre formation de plongee.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'Nouveau plan'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'Outils'; @override String get planning_sidebar_appBar_title => 'Planification'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index fc034b266..db86db3f4 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -2334,10 +2334,10 @@ class AppLocalizationsHe extends AppLocalizations { String get decoCalculator_title => 'מחשבון דקומפרסיה'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'סוג מים'; @override - String get decoCalculator_waterType_standard => 'Standard'; + String get decoCalculator_waterType_standard => 'רגיל'; @override String diveCenters_accessibility_markerLabel(Object name) { @@ -10155,14 +10155,13 @@ class AppLocalizationsHe extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'השוואה'; @override - String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + String get plannerCanvas_compare_needTwo => 'בחר לפחות שתי תוכניות להשוואה'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'השוואת תוכניות'; @override String get plannerCanvas_contingency_base => 'בסיס'; @@ -10247,14 +10246,14 @@ class AppLocalizationsHe extends AppLocalizations { 'נדרשת דקומפרסיה אך לא נלקח גז דקו'; @override - String get plannerCanvas_range_base => 'Base'; + String get plannerCanvas_range_base => 'בסיס'; @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'התאים מציגים את זמן העלייה לפני המים; אדום = לא ניתן לצלול כמתוכנן'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'טבלת טווחים'; @override String get plannerCanvas_results_noDeco => 'לא נדרשת דקומפרסיה'; @@ -10292,24 +10291,24 @@ class AppLocalizationsHe extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'ייבוא'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'לא ניתן לייבא את התוכנית: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'שיתוף קובץ תוכנית'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'ייצוא לוח (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'גז מינימלי'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'נקודת חזרה'; @override String get plannerCanvas_table_depth => 'עומק'; @@ -10400,10 +10399,10 @@ class AppLocalizationsHe extends AppLocalizations { 'כלים אלה מיועדים למטרות תכנון בלבד. תמיד אמת חישובים ופעל לפי הכשרת הצלילה שלך.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'תוכנית חדשה'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'כלים'; @override String get planning_sidebar_appBar_title => 'תכנון'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 60fd240e8..70c3c675f 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -2395,10 +2395,10 @@ class AppLocalizationsHu extends AppLocalizations { String get decoCalculator_title => 'Dekompressziós kalkulátor'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'Víztípus'; @override - String get decoCalculator_waterType_standard => 'Standard'; + String get decoCalculator_waterType_standard => 'Normál'; @override String diveCenters_accessibility_markerLabel(Object name) { @@ -10392,14 +10392,14 @@ class AppLocalizationsHu extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'Összehasonlítás'; @override String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + 'Válassz ki legalább két tervet az összehasonlításhoz'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'Tervek összehasonlítása'; @override String get plannerCanvas_contingency_base => 'Alap'; @@ -10484,14 +10484,14 @@ class AppLocalizationsHu extends AppLocalizations { 'Dekompresszió szükséges, de nincs deco gáz'; @override - String get plannerCanvas_range_base => 'Base'; + String get plannerCanvas_range_base => 'Alap'; @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'A cellák a felszínre érés idejét mutatják; piros = a terv szerint nem merülhető'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'Tartománytáblázat'; @override String get plannerCanvas_results_noDeco => 'Nincs szükség dekompresszióra'; @@ -10529,24 +10529,24 @@ class AppLocalizationsHu extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'Importálás'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'A terv importálása nem sikerült: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'Tervfájl megosztása'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'Tábla exportálása (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'Minimális gáz'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'Forduló'; @override String get plannerCanvas_table_depth => 'Mélység'; @@ -10638,10 +10638,10 @@ class AppLocalizationsHu extends AppLocalizations { 'Ezek az eszkozök kizarolag tervezesi celokat szolgalnak. Mindig ellenorizze a szamitasokat es kövesse merülesi kepzesenek iranyelveit.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'Új terv'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'Eszközök'; @override String get planning_sidebar_appBar_title => 'Tervezes'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 595a3f074..95c9cae56 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -2411,7 +2411,7 @@ class AppLocalizationsIt extends AppLocalizations { String get decoCalculator_title => 'Calcolatore Deco'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'Tipo di acqua'; @override String get decoCalculator_waterType_standard => 'Standard'; @@ -10420,14 +10420,14 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'Confronta'; @override String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + 'Seleziona almeno due piani da confrontare'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'Confronta piani'; @override String get plannerCanvas_contingency_base => 'Base'; @@ -10517,10 +10517,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'Le celle mostrano il tempo di risalita; rosso = non immergibile come pianificato'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'Tabella di variazione'; @override String get plannerCanvas_results_noDeco => @@ -10559,24 +10559,24 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'Importa'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'Impossibile importare il piano: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'Condividi file del piano'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'Esporta lavagnetta (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'Gas minimo'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'Rientro'; @override String get plannerCanvas_table_depth => 'Profondità'; @@ -10668,10 +10668,10 @@ class AppLocalizationsIt extends AppLocalizations { 'Questi strumenti sono solo per la pianificazione. Verifica sempre i calcoli e segui la tua formazione subacquea.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'Nuovo piano'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'Strumenti'; @override String get planning_sidebar_appBar_title => 'Pianificazione'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c7a932c54..5afbe339a 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -2390,10 +2390,10 @@ class AppLocalizationsNl extends AppLocalizations { String get decoCalculator_title => 'Deco calculator'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'Watertype'; @override - String get decoCalculator_waterType_standard => 'Standard'; + String get decoCalculator_waterType_standard => 'Standaard'; @override String diveCenters_accessibility_markerLabel(Object name) { @@ -10343,14 +10343,14 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'Vergelijken'; @override String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + 'Selecteer minstens twee plannen om te vergelijken'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'Plannen vergelijken'; @override String get plannerCanvas_contingency_base => 'Basis'; @@ -10435,14 +10435,14 @@ class AppLocalizationsNl extends AppLocalizations { 'Decompressie vereist maar geen decogas meegenomen'; @override - String get plannerCanvas_range_base => 'Base'; + String get plannerCanvas_range_base => 'Basis'; @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'Cellen tonen de tijd naar de oppervlakte; rood = niet duikbaar zoals gepland'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'Bereiktabel'; @override String get plannerCanvas_results_noDeco => 'Geen decompressie vereist'; @@ -10480,24 +10480,24 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'Importeren'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'Kon plan niet importeren: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'Planbestand delen'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'Slate exporteren (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'Minimum gas'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'Keerpunt'; @override String get plannerCanvas_table_depth => 'Diepte'; @@ -10589,10 +10589,10 @@ class AppLocalizationsNl extends AppLocalizations { 'Deze tools zijn alleen voor planningsdoeleinden. Controleer berekeningen altijd en volg je duikopleiding.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'Nieuw plan'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'Gereedschappen'; @override String get planning_sidebar_appBar_title => 'Planning'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 12d60b261..bc6346b0c 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -2409,10 +2409,10 @@ class AppLocalizationsPt extends AppLocalizations { String get decoCalculator_title => 'Calculadora de Descompressão'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => 'Tipo de água'; @override - String get decoCalculator_waterType_standard => 'Standard'; + String get decoCalculator_waterType_standard => 'Padrão'; @override String diveCenters_accessibility_markerLabel(Object name) { @@ -10426,14 +10426,14 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => 'Comparar'; @override String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + 'Selecione pelo menos dois planos para comparar'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => 'Comparar planos'; @override String get plannerCanvas_contingency_base => 'Base'; @@ -10524,10 +10524,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + 'As células mostram o tempo até a superfície; vermelho = não mergulhável como planejado'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => 'Tabela de variações'; @override String get plannerCanvas_results_noDeco => 'Nenhuma descompressão necessária'; @@ -10565,24 +10565,24 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => 'Importar'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return 'Não foi possível importar o plano: $reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => 'Compartilhar arquivo do plano'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => 'Exportar prancheta (PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => 'Gás mínimo'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => 'Retorno'; @override String get plannerCanvas_table_depth => 'Profundidade'; @@ -10674,10 +10674,10 @@ class AppLocalizationsPt extends AppLocalizations { 'Estas ferramentas sao apenas para fins de planejamento. Sempre verifique os calculos e siga seu treinamento de mergulho.'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => 'Novo plano'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => 'Ferramentas'; @override String get planning_sidebar_appBar_title => 'Planejamento'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index bb6a7eed0..27714d5db 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -2288,10 +2288,10 @@ class AppLocalizationsZh extends AppLocalizations { String get decoCalculator_title => '减压计算器'; @override - String get decoCalculator_waterType => 'Water type'; + String get decoCalculator_waterType => '水体类型'; @override - String get decoCalculator_waterType_standard => 'Standard'; + String get decoCalculator_waterType_standard => '标准'; @override String diveCenters_accessibility_markerLabel(Object name) { @@ -9969,14 +9969,13 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get plannerCanvas_compare_action => 'Compare'; + String get plannerCanvas_compare_action => '比较'; @override - String get plannerCanvas_compare_needTwo => - 'Select at least two plans to compare'; + String get plannerCanvas_compare_needTwo => '请至少选择两个计划进行比较'; @override - String get plannerCanvas_compare_title => 'Compare plans'; + String get plannerCanvas_compare_title => '比较计划'; @override String get plannerCanvas_contingency_base => '基准'; @@ -10058,14 +10057,13 @@ class AppLocalizationsZh extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => '需要减压但未携带减压气体'; @override - String get plannerCanvas_range_base => 'Base'; + String get plannerCanvas_range_base => '基准'; @override - String get plannerCanvas_range_legend => - 'Cells show time to surface; red = not diveable as planned'; + String get plannerCanvas_range_legend => '单元格显示到达水面所需时间;红色 = 无法按计划潜水'; @override - String get plannerCanvas_range_title => 'Range table'; + String get plannerCanvas_range_title => '范围表'; @override String get plannerCanvas_results_noDeco => '无需减压'; @@ -10103,24 +10101,24 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get plannerCanvas_share_import => 'Import'; + String get plannerCanvas_share_import => '导入'; @override String plannerCanvas_share_importFailed(String reason) { - return 'Couldn\'t import plan: $reason'; + return '无法导入计划:$reason'; } @override - String get plannerCanvas_share_menu => 'Share plan file'; + String get plannerCanvas_share_menu => '分享计划文件'; @override - String get plannerCanvas_slate_menu => 'Export slate (PDF)'; + String get plannerCanvas_slate_menu => '导出潜水板(PDF)'; @override - String get plannerCanvas_slate_minGas => 'Min gas'; + String get plannerCanvas_slate_minGas => '最低气量'; @override - String get plannerCanvas_slate_turn => 'Turn'; + String get plannerCanvas_slate_turn => '折返'; @override String get plannerCanvas_table_depth => '深度'; @@ -10206,10 +10204,10 @@ class AppLocalizationsZh extends AppLocalizations { String get planning_info_disclaimer => '这些工具仅供计划参考。请务必验证计算结果并遵循您的潜水训练。'; @override - String get planning_newPlan => 'New plan'; + String get planning_newPlan => '新建计划'; @override - String get planning_section_tools => 'Tools'; + String get planning_section_tools => '工具'; @override String get planning_sidebar_appBar_title => '计划'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index eb131bf53..b29a56f06 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "Aangepast gasmengsel tonen", "decoCalculator_timeValueMin": "{time} min", "decoCalculator_title": "Deco calculator", + "decoCalculator_waterType": "Watertype", + "decoCalculator_waterType_standard": "Standaard", "diveCenters_accessibility_markerLabel": "Duikcentrum: {name}", "diveCenters_accessibility_selected": "geselecteerd", "diveCenters_accessibility_viewDetails": "Bekijk details voor {name}", @@ -3246,6 +3248,9 @@ "plannerCanvas_ccr_switchDepth": "Setpoint-wisseldiepte", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 probleem} other{{count} problemen}}", + "plannerCanvas_compare_action": "Vergelijken", + "plannerCanvas_compare_needTwo": "Selecteer minstens twee plannen om te vergelijken", + "plannerCanvas_compare_title": "Plannen vergelijken", "plannerCanvas_contingency_base": "Basis", "plannerCanvas_contingency_depthDelta": "Extra diepte", "plannerCanvas_contingency_lostGas": "{gas} verloren", @@ -3267,6 +3272,9 @@ "plannerCanvas_issue_minGas": "Fles eindigt onder het rock-bottom-minimum van {pressure}", "plannerCanvas_issue_noBailout": "CCR-decompressieplan heeft geen bailoutgas aan boord", "plannerCanvas_issue_noDecoGas": "Decompressie vereist maar geen decogas meegenomen", + "plannerCanvas_range_base": "Basis", + "plannerCanvas_range_legend": "Cellen tonen de tijd naar de oppervlakte; rood = niet duikbaar zoals gepland", + "plannerCanvas_range_title": "Bereiktabel", "plannerCanvas_results_noDeco": "Geen decompressie vereist", "plannerCanvas_saved_deleteConfirmBody": "'{name}' definitief verwijderen?", "plannerCanvas_saved_deleteConfirmTitle": "Plan verwijderen?", @@ -3276,6 +3284,12 @@ "plannerCanvas_saved_title": "Opgeslagen plannen", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "Importeren", + "plannerCanvas_share_importFailed": "Kon plan niet importeren: {reason}", + "plannerCanvas_share_menu": "Planbestand delen", + "plannerCanvas_slate_menu": "Slate exporteren (PDF)", + "plannerCanvas_slate_minGas": "Minimum gas", + "plannerCanvas_slate_turn": "Keerpunt", "plannerCanvas_table_depth": "Diepte", "plannerCanvas_table_gas": "Gas", "plannerCanvas_table_runtime": "RT", @@ -3302,6 +3316,8 @@ "planning_card_weightCalculator_subtitle": "Aanbevolen gewicht voor je uitrusting", "planning_card_weightCalculator_title": "Gewichtscalculator", "planning_info_disclaimer": "Deze tools zijn alleen voor planningsdoeleinden. Controleer berekeningen altijd en volg je duikopleiding.", + "planning_newPlan": "Nieuw plan", + "planning_section_tools": "Gereedschappen", "planning_sidebar_appBar_title": "Planning", "planning_sidebar_decoCalculator_subtitle": "NDL & decostops", "planning_sidebar_decoCalculator_title": "Decocalculator", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 8737b315d..81cf42f9a 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -675,6 +675,8 @@ "decoCalculator_showCustomMixSemantics": "Mostrar seletor de mistura de gás personalizada", "decoCalculator_timeValueMin": "{time} min", "decoCalculator_title": "Calculadora de Descompressão", + "decoCalculator_waterType": "Tipo de água", + "decoCalculator_waterType_standard": "Padrão", "diveCenters_accessibility_markerLabel": "Centro de mergulho: {name}", "diveCenters_accessibility_selected": "selecionado", "diveCenters_accessibility_viewDetails": "Ver detalhes de {name}", @@ -3246,6 +3248,9 @@ "plannerCanvas_ccr_switchDepth": "Profundidade de troca do setpoint", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, =1{1 problema} other{{count} problemas}}", + "plannerCanvas_compare_action": "Comparar", + "plannerCanvas_compare_needTwo": "Selecione pelo menos dois planos para comparar", + "plannerCanvas_compare_title": "Comparar planos", "plannerCanvas_contingency_base": "Base", "plannerCanvas_contingency_depthDelta": "Profundidade extra", "plannerCanvas_contingency_lostGas": "{gas} perdido", @@ -3267,6 +3272,9 @@ "plannerCanvas_issue_minGas": "O cilindro termina abaixo do mínimo rock bottom de {pressure}", "plannerCanvas_issue_noBailout": "O plano de descompressão CCR não leva gás de bailout", "plannerCanvas_issue_noDecoGas": "Descompressão necessária mas sem gás de deco a bordo", + "plannerCanvas_range_base": "Base", + "plannerCanvas_range_legend": "As células mostram o tempo até a superfície; vermelho = não mergulhável como planejado", + "plannerCanvas_range_title": "Tabela de variações", "plannerCanvas_results_noDeco": "Nenhuma descompressão necessária", "plannerCanvas_saved_deleteConfirmBody": "Excluir permanentemente \"{name}\"?", "plannerCanvas_saved_deleteConfirmTitle": "Excluir plano?", @@ -3276,6 +3284,12 @@ "plannerCanvas_saved_title": "Planos salvos", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "Importar", + "plannerCanvas_share_importFailed": "Não foi possível importar o plano: {reason}", + "plannerCanvas_share_menu": "Compartilhar arquivo do plano", + "plannerCanvas_slate_menu": "Exportar prancheta (PDF)", + "plannerCanvas_slate_minGas": "Gás mínimo", + "plannerCanvas_slate_turn": "Retorno", "plannerCanvas_table_depth": "Profundidade", "plannerCanvas_table_gas": "Gás", "plannerCanvas_table_runtime": "RT", @@ -3302,6 +3316,8 @@ "planning_card_weightCalculator_subtitle": "Peso recomendado para sua configuracao", "planning_card_weightCalculator_title": "Calculadora de Peso", "planning_info_disclaimer": "Estas ferramentas sao apenas para fins de planejamento. Sempre verifique os calculos e siga seu treinamento de mergulho.", + "planning_newPlan": "Novo plano", + "planning_section_tools": "Ferramentas", "planning_sidebar_appBar_title": "Planejamento", "planning_sidebar_decoCalculator_subtitle": "NDL e paradas deco", "planning_sidebar_decoCalculator_title": "Calculadora Deco", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index fded47dc1..5faf83b8a 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -769,6 +769,8 @@ "decoCalculator_showCustomMixSemantics": "显示自定义混合气选择器", "decoCalculator_timeValueMin": "{time} 分钟", "decoCalculator_title": "减压计算器", + "decoCalculator_waterType": "水体类型", + "decoCalculator_waterType_standard": "标准", "diveCenters_accessibility_markerLabel": "潜水中心:{name}", "diveCenters_accessibility_selected": "已选择", "diveCenters_accessibility_viewDetails": "查看 {name} 的详细信息", @@ -3396,6 +3398,9 @@ "plannerCanvas_ccr_switchDepth": "设定点切换深度", "plannerCanvas_chip_cns": "CNS {value}%", "plannerCanvas_chip_issues": "{count, plural, other{{count} 个问题}}", + "plannerCanvas_compare_action": "比较", + "plannerCanvas_compare_needTwo": "请至少选择两个计划进行比较", + "plannerCanvas_compare_title": "比较计划", "plannerCanvas_contingency_base": "基准", "plannerCanvas_contingency_depthDelta": "额外深度", "plannerCanvas_contingency_lostGas": "失去 {gas}", @@ -3417,6 +3422,9 @@ "plannerCanvas_issue_minGas": "气瓶终压低于最低保底气量 {pressure}", "plannerCanvas_issue_noBailout": "CCR 减压计划未携带逃生气体", "plannerCanvas_issue_noDecoGas": "需要减压但未携带减压气体", + "plannerCanvas_range_base": "基准", + "plannerCanvas_range_legend": "单元格显示到达水面所需时间;红色 = 无法按计划潜水", + "plannerCanvas_range_title": "范围表", "plannerCanvas_results_noDeco": "无需减压", "plannerCanvas_saved_deleteConfirmBody": "永久删除“{name}”?", "plannerCanvas_saved_deleteConfirmTitle": "删除计划?", @@ -3426,6 +3434,12 @@ "plannerCanvas_saved_title": "已保存的计划", "plannerCanvas_scrub_bailout": "BO {minutes}′", "plannerCanvas_scrub_readout": "RT {minutes}′ · {depth}", + "plannerCanvas_share_import": "导入", + "plannerCanvas_share_importFailed": "无法导入计划:{reason}", + "plannerCanvas_share_menu": "分享计划文件", + "plannerCanvas_slate_menu": "导出潜水板(PDF)", + "plannerCanvas_slate_minGas": "最低气量", + "plannerCanvas_slate_turn": "折返", "plannerCanvas_table_depth": "深度", "plannerCanvas_table_gas": "气体", "plannerCanvas_table_runtime": "RT", @@ -3452,6 +3466,8 @@ "planning_card_weightCalculator_subtitle": "适合您装备配置的推荐配重", "planning_card_weightCalculator_title": "配重计算器", "planning_info_disclaimer": "这些工具仅供计划参考。请务必验证计算结果并遵循您的潜水训练。", + "planning_newPlan": "新建计划", + "planning_section_tools": "工具", "planning_sidebar_appBar_title": "计划", "planning_sidebar_decoCalculator_subtitle": "NDL & 减压停留", "planning_sidebar_decoCalculator_title": "减压计算器", diff --git a/test/features/planner/plan_slate_pdf_test.dart b/test/features/planner/plan_slate_pdf_test.dart index 8ca120b41..0f4d8037c 100644 --- a/test/features/planner/plan_slate_pdf_test.dart +++ b/test/features/planner/plan_slate_pdf_test.dart @@ -87,7 +87,7 @@ Future> _build(domain.DivePlan plan) { lostGas: contingency.lostGas(plan), rangeTable: rangeService.compute(plan), bailout: const BailoutSolver().solve(plan), - units: UnitFormatter(const AppSettings()), + units: const UnitFormatter(AppSettings()), labels: _labels, ); } From dd7a02e84fd9b663935122082e571384d233c282 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sun, 5 Jul 2026 17:28:00 -0400 Subject: [PATCH 9/9] test(planner): raise phase 7 outputs coverage; address review comments - Volume units respected in the slate PDF gas table + bailout summary and the compare table (UnitFormatter instead of hardcoded L) - .subplan import hardened: cast failures become FormatException, an unknown segment tankKey is rejected, and the import handler catches FileSystemException and a generic fallback so no error escapes the sheet - Cover the saved-plans sheet (compare, duplicate, open, import success + error) and the slate bailout path; add a router-backed test harness --- .../data/services/plan_file_codec.dart | 23 ++- .../data/services/plan_slate_pdf_service.dart | 8 +- .../presentation/pages/plan_compare_page.dart | 7 +- .../widgets/saved_plans_sheet.dart | 14 ++ .../features/planner/contingency_ui_test.dart | 10 +- .../planner/plan_file_codec_test.dart | 58 +++++++ .../features/planner/plan_slate_pdf_test.dart | 9 +- .../planner/saved_plans_sheet_test.dart | 147 ++++++++++++++++++ test/helpers/test_app.dart | 14 ++ 9 files changed, 278 insertions(+), 12 deletions(-) diff --git a/lib/features/planner/data/services/plan_file_codec.dart b/lib/features/planner/data/services/plan_file_codec.dart index ee4187aaf..f98fd20fe 100644 --- a/lib/features/planner/data/services/plan_file_codec.dart +++ b/lib/features/planner/data/services/plan_file_codec.dart @@ -111,8 +111,19 @@ domain.DivePlan subplanFromJson(String source, {DateTime? now}) { throw const FormatException('Plan file carries no plan'); } - final timestamp = now ?? DateTime.now(); + try { + return _planFromMap(plan, now ?? DateTime.now()); + } on FormatException { + rethrow; + } catch (e) { + // A cast/type failure on a malformed or foreign file surfaces as a clean + // FormatException so the import UI can show a friendly message instead of + // crashing on a raw TypeError. + throw FormatException('Malformed .subplan file: $e'); + } +} +domain.DivePlan _planFromMap(Map plan, DateTime timestamp) { // Fresh tank ids, remembering the export keys for segment references. final tankIdByKey = {}; final tanks = []; @@ -140,6 +151,14 @@ domain.DivePlan subplanFromJson(String source, {DateTime? now}) { final segments = []; for (final (index, raw) in (plan['segments'] as List? ?? const []).indexed) { final segment = raw as Map; + // A segment must reference a tank the file actually carries; an unknown + // or missing key would build an unusable plan, so reject the file. + final tankId = tankIdByKey[segment['tankKey']]; + if (tankId == null) { + throw FormatException( + 'Segment references unknown tank "${segment['tankKey']}"', + ); + } segments.add( PlanSegment( id: _uuid.v4(), @@ -149,7 +168,7 @@ domain.DivePlan subplanFromJson(String source, {DateTime? now}) { startDepth: (segment['startDepth'] as num).toDouble(), endDepth: (segment['endDepth'] as num).toDouble(), durationSeconds: (segment['durationSeconds'] as num).toInt(), - tankId: tankIdByKey[segment['tankKey']] ?? '', + tankId: tankId, gasMix: GasMix( o2: (segment['o2'] as num).toDouble(), he: (segment['he'] as num?)?.toDouble() ?? 0.0, diff --git a/lib/features/planner/data/services/plan_slate_pdf_service.dart b/lib/features/planner/data/services/plan_slate_pdf_service.dart index b288666b7..f611804cf 100644 --- a/lib/features/planner/data/services/plan_slate_pdf_service.dart +++ b/lib/features/planner/data/services/plan_slate_pdf_service.dart @@ -247,7 +247,7 @@ class PlanSlatePdfService { pw.TableRow( children: [ cell(labels.gas, header: true), - cell('L', header: true), + cell(units.volumeSymbol, header: true), cell('%', header: true), cell(labels.turnAt, header: true), cell(labels.minGas, header: true), @@ -257,7 +257,7 @@ class PlanSlatePdfService { pw.TableRow( children: [ cell(tankLabel(usage.tankId)), - cell(usage.litersUsed.toStringAsFixed(0)), + cell(units.convertVolume(usage.litersUsed).toStringAsFixed(0)), cell('${usage.percentUsed.toStringAsFixed(0)}%'), cell( usage.turnPressureBar != null @@ -279,8 +279,8 @@ class PlanSlatePdfService { final worst = bailout.worstCase; return pw.Text( 'TTS ${_minutes(worst.ttsSeconds)} @ ${units.formatDepth(worst.depthMeters)} ' - '(${worst.litersRequired.toStringAsFixed(0)} L / ' - '${bailout.availableLiters.toStringAsFixed(0)} L)', + '(${units.formatVolume(worst.litersRequired)} / ' + '${units.formatVolume(bailout.availableLiters)})', style: const pw.TextStyle(fontSize: 10), ); } diff --git a/lib/features/planner/presentation/pages/plan_compare_page.dart b/lib/features/planner/presentation/pages/plan_compare_page.dart index b4574ce0c..e2506b16e 100644 --- a/lib/features/planner/presentation/pages/plan_compare_page.dart +++ b/lib/features/planner/presentation/pages/plan_compare_page.dart @@ -222,7 +222,12 @@ class _CompareBody extends StatelessWidget { l10n.divePlanner_label_gasConsumption, [ for (final p in plans) - '${p.outcome.tankUsages.fold(0, (sum, u) => sum + u.litersUsed).toStringAsFixed(0)} L', + units.formatVolume( + p.outcome.tankUsages.fold( + 0, + (sum, u) => sum + u.litersUsed, + ), + ), ], ), ]; diff --git a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart index de355afe0..fa7fc3fb0 100644 --- a/lib/features/planner/presentation/widgets/saved_plans_sheet.dart +++ b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart @@ -169,6 +169,20 @@ class _SavedPlansSheetState extends ConsumerState { content: Text(l10n.plannerCanvas_share_importFailed(e.message)), ), ); + } on FileSystemException catch (e) { + // An unreadable/missing file should surface the same friendly error + // rather than escaping and tearing down the sheet. + messenger.showSnackBar( + SnackBar( + content: Text(l10n.plannerCanvas_share_importFailed(e.message)), + ), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text(l10n.plannerCanvas_share_importFailed(e.toString())), + ), + ); } } } diff --git a/test/features/planner/contingency_ui_test.dart b/test/features/planner/contingency_ui_test.dart index 681ea6911..e0b81cef3 100644 --- a/test/features/planner/contingency_ui_test.dart +++ b/test/features/planner/contingency_ui_test.dart @@ -101,19 +101,21 @@ void main() { expect(find.text('CONTINGENCIES'), findsOneWidget); // The deviation tables are collapsed by default (they cost extra engine - // runs); expand the section before the sub-tables render. - expect(find.text('+5′'), findsNothing); + // runs); expand the section before the sub-tables render. Assert on the + // depth-deviation label '+5m', which is unique to the contingency section + // (the always-visible range table uses +3m/+6m columns). + expect(find.text('+5m'), findsNothing); expect(container.read(contingenciesExpandedProvider), isFalse); await tester.tap(find.text('CONTINGENCIES')); await tester.pumpAndSettle(); expect(container.read(contingenciesExpandedProvider), isTrue); await tester.scrollUntilVisible( - find.text('+5′'), + find.text('+5m'), 200, scrollable: find.byType(Scrollable).first, ); - expect(find.text('+5′'), findsOneWidget); + expect(find.text('+5m'), findsOneWidget); }); testWidgets('settings section edits deltas, turn rule, and custom fraction', ( diff --git a/test/features/planner/plan_file_codec_test.dart b/test/features/planner/plan_file_codec_test.dart index 99bea5fcf..4c7268070 100644 --- a/test/features/planner/plan_file_codec_test.dart +++ b/test/features/planner/plan_file_codec_test.dart @@ -141,4 +141,62 @@ void main() { throwsFormatException, ); }); + + test('malformed plan bodies raise FormatException, not TypeError', () { + // gfLow required but missing / wrong type -> cast failure becomes a + // FormatException instead of escaping as a raw TypeError. + expect( + () => subplanFromJson( + jsonEncode({ + 'format': subplanFormat, + 'version': subplanVersion, + 'plan': {'name': 'x', 'gfLow': 'not-a-number', 'gfHigh': 80}, + }), + ), + throwsFormatException, + ); + // Tanks is not a list. + expect( + () => subplanFromJson( + jsonEncode({ + 'format': subplanFormat, + 'version': subplanVersion, + 'plan': {'name': 'x', 'gfLow': 40, 'gfHigh': 80, 'tanks': 'nope'}, + }), + ), + throwsFormatException, + ); + }); + + test('a segment referencing an unknown tank is rejected', () { + expect( + () => subplanFromJson( + jsonEncode({ + 'format': subplanFormat, + 'version': subplanVersion, + 'plan': { + 'name': 'x', + 'gfLow': 40, + 'gfHigh': 80, + 'tanks': [ + {'key': 'back', 'o2': 21, 'he': 0, 'role': 'backGas', 'order': 0}, + ], + 'segments': [ + { + 'type': 'bottom', + 'startDepth': 30, + 'endDepth': 30, + 'durationSeconds': 1200, + 'tankKey': 'ghost', + 'o2': 21, + 'he': 0, + 'order': 0, + }, + ], + }, + }), + ), + throwsFormatException, + ); + }); } diff --git a/test/features/planner/plan_slate_pdf_test.dart b/test/features/planner/plan_slate_pdf_test.dart index 0f4d8037c..c93d1a55b 100644 --- a/test/features/planner/plan_slate_pdf_test.dart +++ b/test/features/planner/plan_slate_pdf_test.dart @@ -27,6 +27,13 @@ const _decoTank = DiveTank( gasMix: _ean50, role: TankRole.deco, ); +const _bailoutTank = DiveTank( + id: 'tank-3', + volume: 11.1, + startPressure: 232.0, + gasMix: _air, + role: TankRole.bailout, +); final _labels = PlanSlateLabels( runtimeTable: 'Deco schedule', @@ -69,7 +76,7 @@ domain.DivePlan _plan({domain.PlanMode mode = domain.PlanMode.oc}) { order: 1, ), ], - tanks: const [_airTank, _decoTank], + tanks: const [_airTank, _decoTank, _bailoutTank], createdAt: DateTime(2026, 7, 5), updatedAt: DateTime(2026, 7, 5), ); diff --git a/test/features/planner/saved_plans_sheet_test.dart b/test/features/planner/saved_plans_sheet_test.dart index aade78846..549bfb313 100644 --- a/test/features/planner/saved_plans_sheet_test.dart +++ b/test/features/planner/saved_plans_sheet_test.dart @@ -1,13 +1,19 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.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/data/services/plan_file_codec.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/mock_file_picker_platform.dart'; import '../../helpers/test_app.dart'; import '../../helpers/test_database.dart'; @@ -51,6 +57,47 @@ void main() { child: const SavedPlansSheet(), ); + // A router harness so the sheet's `context.go(...)` calls resolve to stub + // destinations we can assert on. + Widget routerHarness() => testAppRouter( + overrides: [ + settingsProvider.overrideWith((ref) => _TestSettingsNotifier()), + ], + router: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showSavedPlansSheet(context), + child: const Text('open'), + ), + ), + ), + ), + ), + GoRoute( + path: '/planning/dive-planner/compare', + builder: (_, _) => const Scaffold(body: Text('compare-page')), + ), + GoRoute( + path: '/planning/dive-planner/:id', + builder: (_, state) => + Scaffold(body: Text('plan:${state.pathParameters['id']}')), + ), + ], + ), + ); + + Future openSheet(WidgetTester tester) async { + await tester.pumpWidget(routerHarness()); + await tester.pumpAndSettle(); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + testWidgets('lists saved plans', (tester) async { await repository.savePlan(_plan('a', 'Reef dive')); await repository.savePlan(_plan('b', 'Wreck dive')); @@ -86,4 +133,104 @@ void main() { expect(await repository.getAllPlanSummaries(), isEmpty); }); + + testWidgets('duplicate action copies the plan', (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('Duplicate').last); + await tester.pumpAndSettle(); + + expect(await repository.getAllPlanSummaries(), hasLength(2)); + }); + + testWidgets('tapping a plan opens it', (tester) async { + await repository.savePlan(_plan('a', 'Reef dive')); + + await openSheet(tester); + + await tester.tap(find.text('Reef dive')); + await tester.pumpAndSettle(); + expect(find.text('plan:a'), findsOneWidget); + }); + + testWidgets('compare mode selects plans and opens the compare page', ( + tester, + ) async { + await repository.savePlan(_plan('a', 'Reef dive')); + await repository.savePlan(_plan('b', 'Wreck dive')); + + await openSheet(tester); + + // Enter compare mode -> checkboxes appear. + await tester.tap(find.text('Compare')); + await tester.pumpAndSettle(); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + + // Select both, then launch the compare page. + await tester.tap(find.text('Reef dive')); + await tester.tap(find.text('Wreck dive')); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('Compare (2)')); + await tester.pumpAndSettle(); + + expect(find.text('compare-page'), findsOneWidget); + }); + + testWidgets('import reads a .subplan file and opens the imported plan', ( + tester, + ) async { + final dir = Directory.systemTemp.createTempSync('subplan-import'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/plan.subplan') + ..writeAsStringSync(planToSubplanJson(_plan('src', 'Imported reef'))); + final original = FilePickerPlatform.instance; + addTearDown(() => FilePickerPlatform.instance = original); + FilePickerPlatform.instance = MockFilePickerPlatform() + ..pickFilesResult = FilePickerResult([ + PlatformFile(path: file.path, name: 'plan.subplan', size: 0), + ]); + + await openSheet(tester); + // The import does real file + DB I/O; runAsync lets it complete. + await tester.runAsync(() async { + await tester.tap(find.text('Import')); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pumpAndSettle(); + + // Imported with a fresh id, so the route lands on the new plan page. + expect(find.textContaining('plan:'), findsOneWidget); + expect(await repository.getAllPlanSummaries(), hasLength(1)); + }); + + testWidgets('import surfaces a friendly error for a malformed file', ( + tester, + ) async { + final dir = Directory.systemTemp.createTempSync('subplan-bad'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/bad.subplan') + ..writeAsStringSync('this is not a plan'); + final original = FilePickerPlatform.instance; + addTearDown(() => FilePickerPlatform.instance = original); + FilePickerPlatform.instance = MockFilePickerPlatform() + ..pickFilesResult = FilePickerResult([ + PlatformFile(path: file.path, name: 'bad.subplan', size: 0), + ]); + + await openSheet(tester); + await tester.runAsync(() async { + await tester.tap(find.text('Import')); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pumpAndSettle(); + + // The FormatException is caught and surfaced, not thrown out of the sheet. + expect(find.byType(SnackBar), findsOneWidget); + expect(await repository.getAllPlanSummaries(), isEmpty); + }); } diff --git a/test/helpers/test_app.dart b/test/helpers/test_app.dart index de763a327..97d652aee 100644 --- a/test/helpers/test_app.dart +++ b/test/helpers/test_app.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; /// Creates a [ProviderScope]-wrapped [MaterialApp] with localization delegates @@ -23,3 +24,16 @@ Widget testApp({required Widget child, List? overrides}) { ), ); } + +/// Like [testApp] but backed by a [GoRouter], so widgets that call +/// `context.go(...)` can be exercised end to end in a test. +Widget testAppRouter({required GoRouter router, List? overrides}) { + return ProviderScope( + overrides: overrides?.cast() ?? [], + child: MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); +}