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
135 changes: 135 additions & 0 deletions docs/superpowers/plans/2026-07-05-dive-planner-phase6-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Dive Planner Phase 6 — Log Integration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans
> to implement this plan task-by-task.

**Goal:** Wire the planner to the dive log: seed tissues from a logged dive,
auto-fill SAC from logged averages, persist convert-to-dive with a back-link,
and overlay the planned profile on the dive detail chart.

**Architecture:** The engine already accepts `startState` on
`PlanEngine.compute`; this phase feeds it. Followed-dive context lives on
`DivePlanState` (sourceDiveId + initialTissueState + surfaceInterval) and maps
into the `domain.DivePlan` aggregate. Convert-to-dive persists through the
existing `DiveRepository.createDive` and records `linkedDiveId` on the plan;
the dive detail page looks the plan back up by `linkedDiveId` and renders it
as a `ChartSourceOverlay`.

**Tech Stack:** Flutter, Riverpod, Drift, existing deco engine (`BuhlmannGf`).

## Global Constraints

- All deco math flows through the engine already used by dive details.
- Units respect diver settings via `UnitFormatter`.
- New user-facing strings go in `app_en.arb` + all 10 other locales,
alphabetically, then `flutter gen-l10n`.
- `dart format .` clean; whole-project `flutter analyze` clean.
- Commit per task on `worktree-dive-planner-phase6-log`.

---

### Task 1: Followed-dive context on state, notifier, and mapper

**Files:**
- Modify: `lib/features/dive_planner/domain/entities/plan_result.dart`
- Modify: `lib/features/dive_planner/presentation/providers/dive_planner_providers.dart`
- Modify: `lib/features/planner/domain/services/dive_plan_state_mapper.dart`
- Test: `test/features/planner/dive_plan_state_mapper_test.dart`

**Steps:**
- [ ] Add `sourceDiveId` and `linkedDiveId` (`String?`, with
`clearSourceDiveId`/`clearLinkedDiveId` copyWith flags) to `DivePlanState`.
- [ ] Notifier: replace stub `loadTissueFromDive` with
`setFollowedDive({required String diveId, List<TissueCompartment>? compartments, required Duration surfaceInterval})`
(sets sourceDiveId + initialTissueState + surfaceInterval, marks dirty),
add `clearFollowedDive()` (clears all three) and `setLinkedDive(String? diveId)`.
- [ ] `stateFromDivePlan` copies sourceDiveId/linkedDiveId out of the plan;
`divePlanFromState` writes them from the state (state-owned now — update the
preservation test which expects `sourceDiveId` to survive from `existing`).
- [ ] Tests: round-trip both ids; `setFollowedDive`/`clearFollowedDive`
behavior. Run mapper + planner provider tests. Commit.

### Task 2: Tissue seeding into the live outcome

**Files:**
- Modify: `lib/features/planner/presentation/providers/plan_canvas_providers.dart`
- Modify (if needed): `lib/core/deco/deco_model.dart`
- Test: `test/features/planner/plan_engine_seeding_test.dart`

**Steps:**
- [ ] Helper `TissueState? seededStartState(DivePlanState state, {required double gfLow, required double gfHigh})`:
null when `initialTissueState` is null; otherwise off-gas the compartments
at surface for `surfaceInterval` (air) via `BuhlmannAlgorithm` and return
`BuhlmannGf(...).restoreState(...)` — mirroring
`_computeResidualTissueState` in `profile_analysis_provider.dart`.
- [ ] `planOutcomeProvider` passes the seeded state to `engine.compute`.
- [ ] Engine-level test: identical deco plan computed fresh vs seeded with
loaded compartments → seeded plan has strictly more `totalDecoSeconds`;
a long surface interval (>12 h) trends back toward the fresh plan. Commit.

### Task 3: Follow-a-dive picker, Following chip, SAC auto-fill

**Files:**
- Create: `lib/features/planner/presentation/widgets/follow_dive_sheet.dart`
- Modify: `lib/features/planner/presentation/pages/plan_canvas_page.dart`
- Modify: `lib/features/dive_planner/presentation/widgets/plan_settings_panel.dart`
- Modify: `lib/features/planner/presentation/providers/plan_canvas_providers.dart`
- Test: `test/features/planner/follow_dive_test.dart`

**Steps:**
- [ ] `loggedAverageSacProvider` (`FutureProvider<double?>`): statistics
repository `getSacVolumeByTankRole()`, return the `'backGas'` entry.
- [ ] `followDiveSheet`: recent dives (via `divesProvider`, first ~30) as
ListTiles (name, date, depth/duration via UnitFormatter). On tap: read
`profileAnalysisProvider(dive.id).future`, take
`decoStatuses.last.compartments` (null-safe), surface interval =
`now - dive end` clamped to ≥ 10 min (fallback 60 min when unknown), call
`setFollowedDive`, pop.
- [ ] Canvas page: menu entry "Follow a dive"; when `sourceDiveId != null`
show a Following chip next to the status chips with a clear (×) action.
- [ ] Settings panel: "Use logged average" affordance next to the SAC field
that fills from `loggedAverageSacProvider` when available.
- [ ] Widget test: picker sets state; chip shows and clears. Commit.

### Task 4: Convert-to-dive persistence

**Files:**
- Modify: `lib/features/dive_planner/presentation/providers/dive_planner_providers.dart`
- Modify: `lib/features/planner/presentation/pages/plan_canvas_page.dart`
- Test: `test/features/planner/convert_to_dive_test.dart`

**Steps:**
- [ ] `toDive()` gets a fresh UUID (not the plan id), `isPlanned: true`,
dive name from plan name.
- [ ] `_convertToDive`: confirm dialog → `DiveRepository.createDive` (via the
same notifier/provider path other creators use so sync + list refresh
happen), `setLinkedDive(dive.id)`, `save()`, snackbar with a View action
navigating to `/dives/{id}`.
- [ ] Test: converting persists a dive with `isPlanned` and links the plan.
Commit.

### Task 5: Plan-vs-actual overlay on dive detail

**Files:**
- Modify: `lib/features/planner/data/repositories/dive_plan_repository.dart`
- Create: `lib/features/planner/presentation/providers/plan_overlay_provider.dart`
- Modify: `lib/features/dive_log/presentation/pages/dive_detail_page.dart`
- Test: `test/features/planner/plan_overlay_test.dart`

**Steps:**
- [ ] Repository: `getPlanByLinkedDiveId(String diveId)` →
`domain.DivePlan?` (query dive_plans where linked_dive_id = ?).
- [ ] `plannedProfileOverlayProvider` (`FutureProvider.family<ChartSourceOverlay?, String>`):
load plan, run `PlanEngine`, `buildCanvasSeries`, map profile points →
`DiveProfilePoint`; label from l10n, distinct color, `computerId: null`.
- [ ] Dive detail: watch the provider, append to `overlays` when non-null.
- [ ] Test: repository lookup + overlay point mapping. Commit.

### Task 6: l10n + verification sweep + PR

**Steps:**
- [ ] New keys (all locales, alphabetical, `flutter gen-l10n`).
- [ ] `dart format .`; whole-project `flutter analyze`; run
`test/features/planner/` + touched dive_log tests.
- [ ] Commit, push `-u origin worktree-dive-planner-phase6-log --no-verify`
(with `env -u GITHUB_TOKEN`), PR stacked on the Phase 5 branch.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import 'package:submersion/features/dive_log/presentation/providers/gas_switch_p
import 'package:submersion/features/dive_log/presentation/providers/profile_analysis_provider.dart';
import 'package:submersion/features/dive_log/presentation/pages/fullscreen_profile_page.dart';
import 'package:submersion/features/dive_log/presentation/utils/sac_normalization.dart';
import 'package:submersion/features/planner/presentation/providers/plan_overlay_provider.dart';
import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart';
import 'package:submersion/features/dive_log/presentation/providers/profile_playback_provider.dart';
import 'package:submersion/features/dive_log/presentation/providers/profile_tracking_provider.dart';
Expand Down Expand Up @@ -1152,6 +1153,11 @@ class _DiveDetailPageState extends ConsumerState<DiveDetailPage> {
// rows (e.g. right after a split); skip any stale entries instead of
// crashing on the lookup.
final sourceById = {for (final s in dataSources) s.id: s};
// Plan-vs-actual: the planned profile this dive was converted from,
// ghosted next to the actual logged profile.
final plannedOverlay = ref
.watch(plannedProfileOverlayProvider(dive.id))
.valueOrNull;
final overlays = <ChartSourceOverlay>[
for (final id in overlayIds)
if (id != activeSource?.id &&
Expand All @@ -1168,6 +1174,7 @@ class _DiveDetailPageState extends ConsumerState<DiveDetailPage> {
computerId: sourceProfiles[id]!.computerId,
points: sourceProfiles[id]!.points,
),
?plannedOverlay,
];

// Get unit formatter
Expand Down
20 changes: 20 additions & 0 deletions lib/features/dive_planner/domain/entities/plan_result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,12 @@ class DivePlanState extends Equatable {
/// Initial tissue state from previous dive.
final List<TissueCompartment>? initialTissueState;

/// Logged dive this plan follows (tissue seeding source).
final String? sourceDiveId;

/// Dive created from this plan via convert-to-dive.
final String? linkedDiveId;

/// Dive site for the plan.
final String? siteId;

Expand Down Expand Up @@ -542,6 +548,8 @@ class DivePlanState extends Equatable {
this.sacRate = 15.0,
this.surfaceInterval,
this.initialTissueState,
this.sourceDiveId,
this.linkedDiveId,
this.siteId,
this.altitude,
this.mode = PlanMode.oc,
Expand Down Expand Up @@ -597,6 +605,8 @@ class DivePlanState extends Equatable {
double? sacRate,
Duration? surfaceInterval,
List<TissueCompartment>? initialTissueState,
String? sourceDiveId,
String? linkedDiveId,
String? siteId,
double? altitude,
PlanMode? mode,
Expand All @@ -615,6 +625,8 @@ class DivePlanState extends Equatable {
DateTime? updatedAt,
bool clearSurfaceInterval = false,
bool clearInitialTissueState = false,
bool clearSourceDiveId = false,
bool clearLinkedDiveId = false,
bool clearSiteId = false,
bool clearAltitude = false,
bool clearSetpoints = false,
Expand All @@ -633,6 +645,12 @@ class DivePlanState extends Equatable {
initialTissueState: clearInitialTissueState
? null
: (initialTissueState ?? this.initialTissueState),
sourceDiveId: clearSourceDiveId
? null
: (sourceDiveId ?? this.sourceDiveId),
linkedDiveId: clearLinkedDiveId
? null
: (linkedDiveId ?? this.linkedDiveId),
siteId: clearSiteId ? null : (siteId ?? this.siteId),
altitude: clearAltitude ? null : (altitude ?? this.altitude),
mode: mode ?? this.mode,
Expand Down Expand Up @@ -668,6 +686,8 @@ class DivePlanState extends Equatable {
sacRate,
surfaceInterval,
initialTissueState,
sourceDiveId,
linkedDiveId,
siteId,
altitude,
mode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:uuid/uuid.dart';
import 'package:submersion/core/constants/enums.dart';
import 'package:submersion/core/constants/units.dart';
import 'package:submersion/core/deco/entities/dive_environment.dart';
import 'package:submersion/core/deco/entities/tissue_compartment.dart';
import 'package:submersion/core/providers/provider.dart';
import 'package:submersion/features/dive_log/domain/entities/dive.dart';
import 'package:submersion/features/settings/presentation/providers/settings_providers.dart';
Expand Down Expand Up @@ -394,14 +395,42 @@ class DivePlanNotifier extends StateNotifier<DivePlanState> {
);
}

/// Load tissue state from a previous dive (for repetitive planning).
void loadTissueFromDive(Dive previousDive) {
// This would require accessing the profile analysis to get end tissue state
// For now, we'll just set the surface interval
if (previousDive.dateTime.isBefore(DateTime.now())) {
final interval = DateTime.now().difference(previousDive.dateTime);
setSurfaceInterval(interval);
}
/// Follow a logged dive: seed tissues from its end-of-dive compartments
/// and set the surface interval separating it from this plan.
void setFollowedDive({
required String diveId,
List<TissueCompartment>? compartments,
required Duration surfaceInterval,
}) {
state = state.copyWith(
sourceDiveId: diveId,
initialTissueState: compartments,
clearInitialTissueState: compartments == null,
surfaceInterval: surfaceInterval,
isDirty: true,
updatedAt: DateTime.now(),
);
}

/// Stop following a dive: drop the seed tissues and surface interval.
void clearFollowedDive() {
state = state.copyWith(
clearSourceDiveId: true,
clearSurfaceInterval: true,
clearInitialTissueState: true,
isDirty: true,
updatedAt: DateTime.now(),
);
}

/// Record (or clear) the dive created from this plan via convert-to-dive.
void setLinkedDive(String? diveId) {
state = state.copyWith(
linkedDiveId: diveId,
clearLinkedDiveId: diveId == null,
isDirty: true,
updatedAt: DateTime.now(),
);
}

/// Clear repetitive dive settings.
Expand Down Expand Up @@ -439,6 +468,9 @@ class DivePlanNotifier extends StateNotifier<DivePlanState> {
}

/// Convert the plan to a Dive entity for saving.
///
/// The dive gets a fresh id (converting twice yields two dives) and its
/// tanks shed their plan-side ids so the repository generates new rows.
Dive toDive() {
// Generate profile points from segments
final profilePoints = _calculator.generateProfilePoints(state.segments);
Expand All @@ -458,12 +490,13 @@ class DivePlanNotifier extends StateNotifier<DivePlanState> {
final avgDepth = totalTime > 0 ? totalDepthTime / totalTime : 0.0;

return Dive(
id: state.id,
id: _uuid.v4(),
name: state.name,
dateTime: DateTime.now(),
runtime: Duration(seconds: totalTime),
maxDepth: maxDepth,
avgDepth: avgDepth,
tanks: state.tanks,
tanks: [for (final tank in state.tanks) tank.copyWith(id: '')],
profile: profilePoints,
notes: state.notes,
altitude: state.altitude,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:submersion/core/utils/unit_formatter.dart';
import 'package:submersion/features/settings/presentation/providers/settings_providers.dart';
import 'package:submersion/features/dive_planner/domain/entities/plan_result.dart';
import 'package:submersion/features/dive_planner/presentation/providers/dive_planner_providers.dart';
import 'package:submersion/features/planner/presentation/providers/plan_canvas_providers.dart';
import 'package:submersion/l10n/l10n_extension.dart';

/// Panel for configuring dive plan settings (GF, SAC, site).
Expand Down Expand Up @@ -110,6 +111,7 @@ class PlanSettingsPanel extends ConsumerWidget {
),
],
),
_LoggedSacButton(currentSac: planState.sacRate, units: units),
const SizedBox(height: 16),

// Altitude and reserve pressure row
Expand Down Expand Up @@ -162,6 +164,37 @@ class PlanSettingsPanel extends ConsumerWidget {
}
}

/// One-tap SAC auto-fill from the diver's logged average ("from your log").
/// Hidden when no logged average exists or it already matches the plan.
class _LoggedSacButton extends ConsumerWidget {
const _LoggedSacButton({required this.currentSac, required this.units});

final double currentSac;
final UnitFormatter units;

@override
Widget build(BuildContext context, WidgetRef ref) {
final loggedSac = ref.watch(loggedAverageSacProvider).valueOrNull;
if (loggedSac == null || (loggedSac - currentSac).abs() < 0.5) {
return const SizedBox.shrink();
}

final display =
'${units.convertVolume(loggedSac).toStringAsFixed(1)} '
'${units.volumeSymbol}/min';
return Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
icon: const Icon(Icons.history, size: 18),
label: Text(context.l10n.plannerCanvas_sac_useLogged(display)),
onPressed: () => ref
.read(divePlanNotifierProvider.notifier)
.updateSacRate(loggedSac.clamp(8.0, 30.0)),
),
);
}
}

/// Altitude input with group indicator for altitude diving.
class _AltitudeInput extends StatefulWidget {
final double? altitude;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,29 @@ class DivePlanRepository {
}
}

/// The plan converted into [diveId] via convert-to-dive, or null.
/// Backs the plan-vs-actual overlay on the dive detail chart.
Future<domain.DivePlan?> getPlanByLinkedDiveId(String diveId) async {
try {
// Newest first: a duplicated plan can share the link, take the latest.
final rows =
await (_db.select(_db.divePlans)
..where((t) => t.linkedDiveId.equals(diveId))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])
..limit(1))
.get();
if (rows.isEmpty) return null;
return getPlan(rows.first.id);
} catch (e, stackTrace) {
_log.error(
'Failed to load plan linked to dive $diveId',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}

Future<List<domain.DivePlanSummary>> getAllPlanSummaries() async {
final rows = await (_db.select(
_db.divePlans,
Expand Down
Loading