Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions docs/superpowers/plans/2026-07-05-dive-planner-phase7-outputs.md
Original file line number Diff line number Diff line change
@@ -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<List<int>>` (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.
11 changes: 11 additions & 0 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -208,6 +209,16 @@ final appRouterProvider = Provider<GoRouter>((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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +69,8 @@ class DecoCalculatorPage extends ConsumerWidget {
const TimeSlider(),
const SizedBox(height: 16),
const GasMixSelector(),
const SizedBox(height: 16),
const EnvironmentInputs(),
],
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,6 +13,26 @@ final calcDepthProvider = StateProvider<double>((ref) => 18.0);
/// Input state: bottom time in minutes
final calcTimeProvider = StateProvider<int>((ref) => 30);

/// Input state: altitude above sea level in meters (null = sea level)
final calcAltitudeProvider = StateProvider<double?>((ref) => null);

/// Input state: water type (null = legacy standard water)
final calcWaterTypeProvider = StateProvider<WaterType?>((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<DiveEnvironment>((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<double>((ref) => 21.0);

Expand Down Expand Up @@ -70,10 +92,12 @@ final calcDecoStatusProvider = Provider<DecoStatus>((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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WaterType?>(
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,
),
),
],
);
}
}
Loading