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. 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/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/planner/data/services/plan_file_codec.dart b/lib/features/planner/data/services/plan_file_codec.dart new file mode 100644 index 000000000..f98fd20fe --- /dev/null +++ b/lib/features/planner/data/services/plan_file_codec.dart @@ -0,0 +1,222 @@ +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'); + } + + 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 = []; + 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; + // 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(), + 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: tankId, + 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/data/services/plan_slate_pdf_service.dart b/lib/features/planner/data/services/plan_slate_pdf_service.dart new file mode 100644 index 000000000..f611804cf --- /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(units.volumeSymbol, 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(units.convertVolume(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)} ' + '(${units.formatVolume(worst.litersRequired)} / ' + '${units.formatVolume(bailout.availableLiters)})', + 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/domain/services/contingency_service.dart b/lib/features/planner/domain/services/contingency_service.dart index 6a46df32d..4fd0e307c 100644 --- a/lib/features/planner/domain/services/contingency_service.dart +++ b/lib/features/planner/domain/services/contingency_service.dart @@ -101,37 +101,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/lib/features/planner/presentation/pages/plan_canvas_page.dart b/lib/features/planner/presentation/pages/plan_canvas_page.dart index 4df0aebd0..60777dbff 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,11 @@ 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; +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 +26,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'; @@ -134,6 +140,16 @@ class _PlanCanvasPageState extends ConsumerState { Icons.scuba_diving, context.l10n.divePlanner_action_convertToDive, ), + _menuItem( + 'slate', + Icons.picture_as_pdf, + context.l10n.plannerCanvas_slate_menu, + ), + _menuItem( + 'share', + Icons.ios_share, + context.l10n.plannerCanvas_share_menu, + ), _menuItem( 'reset', Icons.refresh, @@ -173,11 +189,69 @@ class _PlanCanvasPageState extends ConsumerState { _showSettingsSheet(context); case 'convert': _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); + 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/pages/plan_compare_page.dart b/lib/features/planner/presentation/pages/plan_compare_page.dart new file mode 100644 index 000000000..e2506b16e --- /dev/null +++ b/lib/features/planner/presentation/pages/plan_compare_page.dart @@ -0,0 +1,272 @@ +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) + units.formatVolume( + p.outcome.tankUsages.fold( + 0, + (sum, u) => sum + u.litersUsed, + ), + ), + ], + ), + ]; + + 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/providers/plan_canvas_providers.dart b/lib/features/planner/presentation/providers/plan_canvas_providers.dart index 97c22c0f7..9e859101f 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'; @@ -258,6 +259,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 032ce977e..d4833eb85 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..6ab8b9624 --- /dev/null +++ b/lib/features/planner/presentation/widgets/range_table_section.dart @@ -0,0 +1,99 @@ +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/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/features/planner/presentation/widgets/saved_plans_sheet.dart b/lib/features/planner/presentation/widgets/saved_plans_sheet.dart index fbbd135e8..fa7fc3fb0 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'; @@ -18,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)); @@ -38,9 +52,36 @@ 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, + ), + ), + 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), + onPressed: () => _importPlan(context, ref), + ), + ], ), const SizedBox(height: 12), if (plans == null && summaries.isLoading) @@ -63,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})', + ), ), ), ], @@ -72,6 +146,45 @@ 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)), + ), + ); + } 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())), + ), + ); + } + } } class _PlanTile extends ConsumerWidget { @@ -105,6 +218,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 +240,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/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_ar.arb b/lib/l10n/arb/app_ar.arb index 15596662e..c87280765 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}", @@ -3247,6 +3249,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}", @@ -3268,6 +3273,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": "حذف الخطة؟", @@ -3277,6 +3285,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", @@ -3303,6 +3317,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 0bf35df47..eaa318966 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", @@ -3247,6 +3249,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", @@ -3268,6 +3273,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?", @@ -3277,6 +3285,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", @@ -3303,6 +3317,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_en.arb b/lib/l10n/arb/app_en.arb index 1f6a27d5f..a71f9227d 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}", @@ -5678,6 +5680,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}", @@ -5740,7 +5745,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": { @@ -5748,12 +5762,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", @@ -5770,6 +5778,17 @@ "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", "plannerCanvas_table_depth": "Depth", "plannerCanvas_table_gas": "Gas", "plannerCanvas_table_runtime": "RT", @@ -5796,6 +5815,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_es.arb b/lib/l10n/arb/app_es.arb index 6a3574f3e..be700cc3c 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}", @@ -3247,6 +3249,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", @@ -3268,6 +3273,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?", @@ -3277,6 +3285,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", @@ -3303,6 +3317,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 c1101cc54..5a011a22b 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}", @@ -3213,6 +3215,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", @@ -3234,6 +3239,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 ?", @@ -3243,6 +3251,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", @@ -3269,6 +3283,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 5191a6abc..d63362f6c 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}", @@ -3213,6 +3215,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}", @@ -3234,6 +3239,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": "למחוק את התוכנית?", @@ -3243,6 +3251,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", @@ -3269,6 +3283,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 28c5929a4..17f4aceb5 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}", @@ -3213,6 +3215,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", @@ -3234,6 +3239,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?", @@ -3243,6 +3251,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", @@ -3269,6 +3283,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 8f0f37575..cade8939d 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}", @@ -3213,6 +3215,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", @@ -3234,6 +3239,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?", @@ -3243,6 +3251,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", @@ -3269,6 +3283,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.dart b/lib/l10n/arb/app_localizations.dart index cf038e2a3..73e6c1770 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: @@ -17726,6 +17738,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: @@ -17852,12 +17882,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: @@ -17870,12 +17924,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: @@ -17906,6 +17954,42 @@ 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: + /// **'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: @@ -18062,6 +18146,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 9cb1ca18e..ba44c75e0 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 => 'نوع الماء'; + + @override + String get decoCalculator_waterType_standard => 'قياسي'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'مركز غوص: $name'; @@ -10219,6 +10225,15 @@ class AppLocalizationsAr extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'مقارنة'; + + @override + String get plannerCanvas_compare_needTwo => 'اختر خطتين على الأقل للمقارنة'; + + @override + String get plannerCanvas_compare_title => 'مقارنة الخطط'; + @override String get plannerCanvas_contingency_base => 'أساسي'; @@ -10302,21 +10317,31 @@ class AppLocalizationsAr extends AppLocalizations { 'يلزم تخفيف الضغط ولكن لا يوجد غاز تخفيف'; @override - String get plannerCanvas_results_noDeco => 'لا يلزم تخفيف الضغط'; + String get plannerCanvas_range_base => 'أساسي'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'حذف \"$name\" نهائيًا؟'; - } + String get plannerCanvas_range_legend => + 'تعرض الخلايا زمن الصعود إلى السطح؛ الأحمر = غير قابلة للغطس كما هو مخطط'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'حذف الخطة؟'; + String get plannerCanvas_range_title => 'جدول النطاقات'; + + @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 => 'تكرار'; @@ -10336,6 +10361,26 @@ class AppLocalizationsAr extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'استيراد'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'تعذر استيراد الخطة: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'مشاركة ملف الخطة'; + + @override + String get plannerCanvas_slate_menu => 'تصدير اللوح (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'الحد الأدنى من الغاز'; + + @override + String get plannerCanvas_slate_turn => 'العودة'; + @override String get plannerCanvas_table_depth => 'العمق'; @@ -10425,6 +10470,12 @@ class AppLocalizationsAr extends AppLocalizations { String get planning_info_disclaimer => 'هذه الأدوات لأغراض التخطيط فقط. تحقق دائمًا من الحسابات واتبع تدريبك على الغوص.'; + @override + String get planning_newPlan => 'خطة جديدة'; + + @override + 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 45c0ae93a..afee1bc70 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 => 'Wassertyp'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Tauchcenter: $name'; @@ -10413,6 +10419,16 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Vergleichen'; + + @override + String get plannerCanvas_compare_needTwo => + 'Wähle mindestens zwei Pläne zum Vergleichen'; + + @override + String get plannerCanvas_compare_title => 'Pläne vergleichen'; + @override String get plannerCanvas_contingency_base => 'Basis'; @@ -10496,21 +10512,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 => 'Basis'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return '„$name“ endgültig löschen?'; - } + String get plannerCanvas_range_legend => + 'Zellen zeigen die Zeit bis zur Oberfläche; rot = nicht wie geplant tauchbar'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Plan löschen?'; + String get plannerCanvas_range_title => 'Bereichstabelle'; + + @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'; @@ -10530,6 +10556,26 @@ class AppLocalizationsDe extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Importieren'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Plan konnte nicht importiert werden: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Plandatei teilen'; + + @override + String get plannerCanvas_slate_menu => 'Slate exportieren (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Min-Gas'; + + @override + String get plannerCanvas_slate_turn => 'Umkehr'; + @override String get plannerCanvas_table_depth => 'Tiefe'; @@ -10619,6 +10665,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 => 'Neuer Plan'; + + @override + String get planning_section_tools => 'Werkzeuge'; + @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 6f26dc718..20f5e7caf 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'; @@ -10252,6 +10258,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'; @@ -10335,21 +10351,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'; @@ -10369,6 +10395,26 @@ 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)'; + + @override + String get plannerCanvas_slate_minGas => 'Min gas'; + + @override + String get plannerCanvas_slate_turn => 'Turn'; + @override String get plannerCanvas_table_depth => 'Depth'; @@ -10458,6 +10504,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 3274fb6da..a2606adf6 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 => 'Tipo de agua'; + + @override + String get decoCalculator_waterType_standard => 'Estándar'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centro de buceo: $name'; @@ -10402,6 +10408,16 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Comparar'; + + @override + String get plannerCanvas_compare_needTwo => + 'Selecciona al menos dos planes para comparar'; + + @override + String get plannerCanvas_compare_title => 'Comparar planes'; + @override String get plannerCanvas_contingency_base => 'Base'; @@ -10487,21 +10503,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 => + 'Las celdas muestran el tiempo hasta la superficie; rojo = no buceable según lo planeado'; @override - String get plannerCanvas_saved_deleteConfirmTitle => '¿Eliminar plan?'; + String get plannerCanvas_range_title => 'Tabla de rangos'; + + @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'; @@ -10521,6 +10547,26 @@ class AppLocalizationsEs extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Importar'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'No se pudo importar el plan: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Compartir archivo del plan'; + + @override + String get plannerCanvas_slate_menu => 'Exportar pizarra (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Gas mínimo'; + + @override + String get plannerCanvas_slate_turn => 'Retorno'; + @override String get plannerCanvas_table_depth => 'Profundidad'; @@ -10610,6 +10656,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 => 'Nuevo plan'; + + @override + 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 78e81dff9..996f7cae8 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 => 'Type d\'eau'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centre de plongée : $name'; @@ -10452,6 +10458,16 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Comparer'; + + @override + String get plannerCanvas_compare_needTwo => + 'Sélectionnez au moins deux plans à comparer'; + + @override + String get plannerCanvas_compare_title => 'Comparer les plans'; + @override String get plannerCanvas_contingency_base => 'Base'; @@ -10537,21 +10553,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 => + 'Les cellules indiquent le temps de remontée ; rouge = non plongeable comme prévu'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Supprimer le plan ?'; + String get plannerCanvas_range_title => 'Table de variations'; + + @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'; @@ -10571,6 +10597,26 @@ class AppLocalizationsFr extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Importer'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Impossible d\'importer le plan : $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Partager le fichier du plan'; + + @override + String get plannerCanvas_slate_menu => 'Exporter la plaquette (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Gaz minimum'; + + @override + String get plannerCanvas_slate_turn => 'Demi-tour'; + @override String get plannerCanvas_table_depth => 'Profondeur'; @@ -10660,6 +10706,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 => 'Nouveau plan'; + + @override + 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 bc2b73cb6..b00ca3473 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 => 'סוג מים'; + + @override + String get decoCalculator_waterType_standard => 'רגיל'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'מרכז צלילה: $name'; @@ -10151,6 +10157,15 @@ class AppLocalizationsHe extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'השוואה'; + + @override + String get plannerCanvas_compare_needTwo => 'בחר לפחות שתי תוכניות להשוואה'; + + @override + String get plannerCanvas_compare_title => 'השוואת תוכניות'; + @override String get plannerCanvas_contingency_base => 'בסיס'; @@ -10234,21 +10249,31 @@ class AppLocalizationsHe extends AppLocalizations { 'נדרשת דקומפרסיה אך לא נלקח גז דקו'; @override - String get plannerCanvas_results_noDeco => 'לא נדרשת דקומפרסיה'; + String get plannerCanvas_range_base => 'בסיס'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'למחוק לצמיתות את \"$name\"?'; - } + String get plannerCanvas_range_legend => + 'התאים מציגים את זמן העלייה לפני המים; אדום = לא ניתן לצלול כמתוכנן'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'למחוק את התוכנית?'; + String get plannerCanvas_range_title => 'טבלת טווחים'; + + @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 => 'שכפול'; @@ -10268,6 +10293,26 @@ class AppLocalizationsHe extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'ייבוא'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'לא ניתן לייבא את התוכנית: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'שיתוף קובץ תוכנית'; + + @override + String get plannerCanvas_slate_menu => 'ייצוא לוח (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'גז מינימלי'; + + @override + String get plannerCanvas_slate_turn => 'נקודת חזרה'; + @override String get plannerCanvas_table_depth => 'עומק'; @@ -10356,6 +10401,12 @@ class AppLocalizationsHe extends AppLocalizations { String get planning_info_disclaimer => 'כלים אלה מיועדים למטרות תכנון בלבד. תמיד אמת חישובים ופעל לפי הכשרת הצלילה שלך.'; + @override + String get planning_newPlan => 'תוכנית חדשה'; + + @override + 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 c1b1d5852..7e22abb08 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 => 'Víztípus'; + + @override + String get decoCalculator_waterType_standard => 'Normál'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Búvárközpont: $name'; @@ -10389,6 +10395,16 @@ class AppLocalizationsHu extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Összehasonlítás'; + + @override + String get plannerCanvas_compare_needTwo => + 'Válassz ki legalább két tervet az összehasonlításhoz'; + + @override + String get plannerCanvas_compare_title => 'Tervek összehasonlítása'; + @override String get plannerCanvas_contingency_base => 'Alap'; @@ -10472,21 +10488,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 => 'Alap'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return 'Véglegesen törli a(z) „$name” tervet?'; - } + String get plannerCanvas_range_legend => + 'A cellák a felszínre érés idejét mutatják; piros = a terv szerint nem merülhető'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Törli a tervet?'; + String get plannerCanvas_range_title => 'Tartománytáblázat'; + + @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'; @@ -10506,6 +10532,26 @@ class AppLocalizationsHu extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Importálás'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'A terv importálása nem sikerült: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Tervfájl megosztása'; + + @override + String get plannerCanvas_slate_menu => 'Tábla exportálása (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Minimális gáz'; + + @override + String get plannerCanvas_slate_turn => 'Forduló'; + @override String get plannerCanvas_table_depth => 'Mélység'; @@ -10595,6 +10641,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 => 'Új terv'; + + @override + 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 b6ec8b360..fd6d6f549 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 => 'Tipo di acqua'; + + @override + String get decoCalculator_waterType_standard => 'Standard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centro immersioni: $name'; @@ -10417,6 +10423,16 @@ class AppLocalizationsIt extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Confronta'; + + @override + String get plannerCanvas_compare_needTwo => + 'Seleziona almeno due piani da confrontare'; + + @override + String get plannerCanvas_compare_title => 'Confronta piani'; + @override String get plannerCanvas_contingency_base => 'Base'; @@ -10500,10 +10516,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 => + 'Le celle mostrano il tempo di risalita; rosso = non immergibile come pianificato'; + + @override + String get plannerCanvas_range_title => 'Tabella di variazione'; + @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»?'; @@ -10512,11 +10543,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'; @@ -10536,6 +10562,26 @@ class AppLocalizationsIt extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Importa'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Impossibile importare il piano: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Condividi file del piano'; + + @override + String get plannerCanvas_slate_menu => 'Esporta lavagnetta (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Gas minimo'; + + @override + String get plannerCanvas_slate_turn => 'Rientro'; + @override String get plannerCanvas_table_depth => 'Profondità'; @@ -10625,6 +10671,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 => 'Nuovo piano'; + + @override + 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 710f6aec0..a48c2b498 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 => 'Watertype'; + + @override + String get decoCalculator_waterType_standard => 'Standaard'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Duikcentrum: $name'; @@ -10340,6 +10346,16 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Vergelijken'; + + @override + String get plannerCanvas_compare_needTwo => + 'Selecteer minstens twee plannen om te vergelijken'; + + @override + String get plannerCanvas_compare_title => 'Plannen vergelijken'; + @override String get plannerCanvas_contingency_base => 'Basis'; @@ -10423,21 +10439,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 => 'Basis'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return '\'$name\' definitief verwijderen?'; - } + String get plannerCanvas_range_legend => + 'Cellen tonen de tijd naar de oppervlakte; rood = niet duikbaar zoals gepland'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Plan verwijderen?'; + String get plannerCanvas_range_title => 'Bereiktabel'; + + @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'; @@ -10457,6 +10483,26 @@ class AppLocalizationsNl extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Importeren'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Kon plan niet importeren: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Planbestand delen'; + + @override + String get plannerCanvas_slate_menu => 'Slate exporteren (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Minimum gas'; + + @override + String get plannerCanvas_slate_turn => 'Keerpunt'; + @override String get plannerCanvas_table_depth => 'Diepte'; @@ -10546,6 +10592,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 => 'Nieuw plan'; + + @override + 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 95b6d6029..f3e2a978a 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 => 'Tipo de água'; + + @override + String get decoCalculator_waterType_standard => 'Padrão'; + @override String diveCenters_accessibility_markerLabel(Object name) { return 'Centro de mergulho: $name'; @@ -10423,6 +10429,16 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => 'Comparar'; + + @override + String get plannerCanvas_compare_needTwo => + 'Selecione pelo menos dois planos para comparar'; + + @override + String get plannerCanvas_compare_title => 'Comparar planos'; + @override String get plannerCanvas_contingency_base => 'Base'; @@ -10508,21 +10524,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 => + 'As células mostram o tempo até a superfície; vermelho = não mergulhável como planejado'; @override - String get plannerCanvas_saved_deleteConfirmTitle => 'Excluir plano?'; + String get plannerCanvas_range_title => 'Tabela de variações'; + + @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'; @@ -10542,6 +10568,26 @@ class AppLocalizationsPt extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => 'Importar'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return 'Não foi possível importar o plano: $reason'; + } + + @override + String get plannerCanvas_share_menu => 'Compartilhar arquivo do plano'; + + @override + String get plannerCanvas_slate_menu => 'Exportar prancheta (PDF)'; + + @override + String get plannerCanvas_slate_minGas => 'Gás mínimo'; + + @override + String get plannerCanvas_slate_turn => 'Retorno'; + @override String get plannerCanvas_table_depth => 'Profundidade'; @@ -10631,6 +10677,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 => 'Novo plano'; + + @override + 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 098804012..b834ce1a5 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 => '水体类型'; + + @override + String get decoCalculator_waterType_standard => '标准'; + @override String diveCenters_accessibility_markerLabel(Object name) { return '潜水中心:$name'; @@ -9965,6 +9971,15 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String get plannerCanvas_compare_action => '比较'; + + @override + String get plannerCanvas_compare_needTwo => '请至少选择两个计划进行比较'; + + @override + String get plannerCanvas_compare_title => '比较计划'; + @override String get plannerCanvas_contingency_base => '基准'; @@ -10045,21 +10060,30 @@ class AppLocalizationsZh extends AppLocalizations { String get plannerCanvas_issue_noDecoGas => '需要减压但未携带减压气体'; @override - String get plannerCanvas_results_noDeco => '无需减压'; + String get plannerCanvas_range_base => '基准'; @override - String plannerCanvas_saved_deleteConfirmBody(String name) { - return '永久删除“$name”?'; - } + String get plannerCanvas_range_legend => '单元格显示到达水面所需时间;红色 = 无法按计划潜水'; @override - String get plannerCanvas_saved_deleteConfirmTitle => '删除计划?'; + String get plannerCanvas_range_title => '范围表'; + + @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 => '复制'; @@ -10079,6 +10103,26 @@ class AppLocalizationsZh extends AppLocalizations { return 'RT $minutes′ · $depth'; } + @override + String get plannerCanvas_share_import => '导入'; + + @override + String plannerCanvas_share_importFailed(String reason) { + return '无法导入计划:$reason'; + } + + @override + String get plannerCanvas_share_menu => '分享计划文件'; + + @override + String get plannerCanvas_slate_menu => '导出潜水板(PDF)'; + + @override + String get plannerCanvas_slate_minGas => '最低气量'; + + @override + String get plannerCanvas_slate_turn => '折返'; + @override String get plannerCanvas_table_depth => '深度'; @@ -10162,6 +10206,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get planning_info_disclaimer => '这些工具仅供计划参考。请务必验证计算结果并遵循您的潜水训练。'; + @override + String get planning_newPlan => '新建计划'; + + @override + 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 09fb17aa8..75b4a9da0 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}", @@ -3247,6 +3249,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", @@ -3268,6 +3273,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?", @@ -3277,6 +3285,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", @@ -3303,6 +3317,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 485cf3ddc..037ee5d11 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}", @@ -3247,6 +3249,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", @@ -3268,6 +3273,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?", @@ -3277,6 +3285,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", @@ -3303,6 +3317,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 decfd4d76..3a6ce6fe0 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} 的详细信息", @@ -3397,6 +3399,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}", @@ -3418,6 +3423,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": "删除计划?", @@ -3427,6 +3435,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", @@ -3453,6 +3467,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/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_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); + }); +} 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..4c7268070 --- /dev/null +++ b/test/features/planner/plan_file_codec_test.dart @@ -0,0 +1,202 @@ +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, + ); + }); + + 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 new file mode 100644 index 000000000..c93d1a55b --- /dev/null +++ b/test/features/planner/plan_slate_pdf_test.dart @@ -0,0 +1,114 @@ +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, +); +const _bailoutTank = DiveTank( + id: 'tank-3', + volume: 11.1, + startPressure: 232.0, + gasMix: _air, + role: TankRole.bailout, +); + +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, _bailoutTank], + 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: const UnitFormatter(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-'); + }); +} 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); + }); +} 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); + }); + }); +} 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/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)); + }); +} 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, + ), + ); +}