Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bfedf25
Add design spec for CCR equipment support (#804)
ericgriffin Aug 5, 2026
a68bcb5
Add implementation plan for CCR equipment support (#804)
ericgriffin Aug 5, 2026
cadc8ec
Add rebreather equipment type (#804)
ericgriffin Aug 5, 2026
1473fdc
Add rebreather attribute catalog entry (#804)
ericgriffin Aug 5, 2026
053f6a7
Localize rebreather equipment attributes (#804)
ericgriffin Aug 5, 2026
a3105df
Add rebreather attribute persistence test (#804)
ericgriffin Aug 5, 2026
f59c968
Allow built-in service kinds to declare hour intervals (#804)
ericgriffin Aug 5, 2026
2ffcf79
Seed rebreather service kinds for scrubber, cells, and annual (#804)
ericgriffin Aug 5, 2026
73c0c45
Caption hours-based service clocks with their data source (#804)
ericgriffin Aug 5, 2026
e343488
Add cylinder configuration domain entities (#804)
ericgriffin Aug 5, 2026
7c66c63
Add cylinder configuration merge algorithm (#804)
ericgriffin Aug 5, 2026
6661c1f
Add cylinder configuration tables at schema v139 (#804)
ericgriffin Aug 5, 2026
da486cc
Add cylinder configuration repository (#804)
ericgriffin Aug 5, 2026
7195f18
Register cylinder configurations as synced entities (#804)
ericgriffin Aug 5, 2026
07c4a97
Add cylinder configuration providers (#804)
ericgriffin Aug 5, 2026
4488b63
Add cylinder configuration list and edit pages (#804)
ericgriffin Aug 5, 2026
9d6d927
Apply cylinder configurations to a dive's cylinders (#804)
ericgriffin Aug 5, 2026
71f7a1e
Show cylinder configurations on the rebreather detail page (#804)
ericgriffin Aug 5, 2026
1089646
Address review feedback on PR #868
ericgriffin Aug 6, 2026
177b7c5
Raise patch coverage on the cylinder configuration surfaces
ericgriffin Aug 6, 2026
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
2,756 changes: 2,756 additions & 0 deletions docs/superpowers/plans/2026-08-05-ccr-equipment.md

Large diffs are not rendered by default.

347 changes: 347 additions & 0 deletions docs/superpowers/specs/2026-08-05-ccr-equipment-design.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lib/core/constants/enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum EquipmentType {
computer('Dive Computer'),
transmitter('Transmitter'),
tank('Tank'),
rebreather('Rebreather'),
weights('Weights'),
light('Light'),
camera('Camera'),
Expand Down
155 changes: 144 additions & 11 deletions lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2116,30 +2116,94 @@ const String kSeedBuiltInServiceKindsSql = '''
(id, diver_id, name, applicable_types, default_interval_days,
default_interval_dives, default_interval_hours, auto_attach,
is_built_in, created_at, updated_at)
SELECT t.id, NULL, t.name, t.types, t.days, t.dives, NULL, t.auto, 1,
SELECT t.id, NULL, t.name, t.types, t.days, t.dives, t.hours, t.auto, 1,
n.now_ms, n.now_ms
FROM (
SELECT 'hydro' AS id, 'Hydrostatic test' AS name, '["tank"]' AS types,
1825 AS days, NULL AS dives, 1 AS auto
1825 AS days, NULL AS dives, NULL AS hours, 1 AS auto
UNION ALL SELECT 'vip', 'Visual inspection (VIP)', '["tank"]',
365, NULL, 1
UNION ALL SELECT 'o2-clean', 'O2 clean', '["tank"]', 365, NULL, 0
365, NULL, NULL, 1
UNION ALL SELECT 'o2-clean', 'O2 clean', '["tank"]', 365, NULL, NULL, 0
UNION ALL SELECT 'regulator-service', 'Regulator service',
'["regulator"]', 365, 100, 1
'["regulator"]', 365, 100, NULL, 1
UNION ALL SELECT 'computer-battery', 'Computer battery', '["computer"]',
730, NULL, 1
730, NULL, NULL, 1
UNION ALL SELECT 'transmitter-battery', 'Transmitter battery',
'["transmitter"]', 365, NULL, 1
'["transmitter"]', 365, NULL, NULL, 1
UNION ALL SELECT 'bcd-inspection', 'BCD/wing inspection', '["bcd"]',
365, NULL, 1
365, NULL, NULL, 1
UNION ALL SELECT 'drysuit-seals', 'Drysuit seals', '["drysuit"]',
730, NULL, 0
730, NULL, NULL, 0
-- A scrubber is consumed by loop time, not by the calendar, so this is
-- the only built-in with an hours-only clock. 3.0 h is conservative
-- across the 2-6 h range real units are rated for; the diver overrides
-- it per unit via ServiceSchedule.intervalHours.
UNION ALL SELECT 'scrubber-repack', 'Scrubber repack', '["rebreather"]',
NULL, NULL, 3.0, 1
UNION ALL SELECT 'o2-cell-replacement', 'O2 cell replacement',
'["rebreather"]', 365, NULL, NULL, 1
UNION ALL SELECT 'rebreather-annual', 'Rebreather annual service',
'["rebreather"]', 365, NULL, NULL, 1
UNION ALL SELECT 'general-service', 'General service', '[]',
NULL, NULL, 0
NULL, NULL, NULL, 0
) t
CROSS JOIN (SELECT CAST(strftime('%s','now') AS INTEGER) * 1000 AS now_ms) n
''';

/// A named, reusable set of cylinders. equipment_id set means "a config for
/// this rebreather"; null means a generic gas plan usable on any dive.
/// ON DELETE SET NULL demotes a config when its unit is deleted rather than
/// destroying a painstakingly entered bailout plan.
class CylinderConfigs extends Table {
TextColumn get id => text()();
TextColumn get diverId => text().nullable().references(Divers, #id)();
TextColumn get equipmentId => text().nullable().references(
Equipment,
#id,
onDelete: KeyAction.setNull,
)();
TextColumn get name => text()();
TextColumn get description => text().withDefault(const Constant(''))();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();

/// Hybrid Logical Clock for cross-device conflict resolution
/// (nullable: rows written before HLC rollout fall back to updatedAt).
TextColumn get hlc => text().nullable()();

@override
Set<Column> get primaryKey => {id};
}

/// One cylinder in a configuration. The spec columns are a SNAPSHOT: a tank
/// preset may populate them at edit time, but there is deliberately no FK to
/// tank_presets. A config records what the diver actually dives, so a later
/// edit to a preset must not rewrite the meaning of a saved config.
class CylinderConfigItems extends Table {
TextColumn get id => text()();
TextColumn get configId =>
text().references(CylinderConfigs, #id, onDelete: KeyAction.cascade)();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
TextColumn get label => text().nullable()();
TextColumn get tankRole => text()(); // TankRole.name
RealColumn get volumeL => real().nullable()();
RealColumn get workingPressureBar => real().nullable()();
TextColumn get tankMaterial => text().nullable()(); // TankMaterial.name
RealColumn get o2Percent => real().withDefault(const Constant(21.0))();
RealColumn get hePercent => real().withDefault(const Constant(0.0))();
RealColumn get defaultStartPressureBar => real().nullable()();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();

/// Hybrid Logical Clock for cross-device conflict resolution
/// (nullable: rows written before HLC rollout fall back to updatedAt).
TextColumn get hlc => text().nullable()();

@override
Set<Column> get primaryKey => {id};
}

/// Custom tank presets (user-defined tank configurations)
class TankPresets extends Table {
TextColumn get id => text()();
Expand Down Expand Up @@ -2852,6 +2916,8 @@ String legacyDataSourceId(String diveId) => '$kLegacyDataSourceIdPrefix$diveId';
ConnectedAccounts,
ServiceKinds,
ServiceSchedules,
CylinderConfigs,
CylinderConfigItems,
],
)
class AppDatabase extends _$AppDatabase {
Expand All @@ -2861,7 +2927,7 @@ class AppDatabase extends _$AppDatabase {

/// The current schema version as a static constant so that pre-open checks
/// (e.g. version-mismatch guard) can reference it without an instance.
static const int currentSchemaVersion = 137;
static const int currentSchemaVersion = 139;

/// Every schema version that has a migration block in onUpgrade.
/// Used to calculate progress step counts. When adding a new migration,
Expand Down Expand Up @@ -3030,6 +3096,10 @@ class AppDatabase extends _$AppDatabase {
// v137: dives.weather_code, plus a one-time clear of the English weather
// prose this app generated itself so it can be re-rendered localized.
137,
// v138 is reserved by the divelogs.de branch (connected_accounts.diver_id).
// v139: cylinder_configs + cylinder_config_items (reusable diluent and
// bailout setups).
139,
];

/// Idempotent DDL for the v106 connector-suggestion columns (Lightroom
Expand Down Expand Up @@ -3394,6 +3464,60 @@ class AppDatabase extends _$AppDatabase {
);
}

/// v139: cylinder configuration tables. CREATE TABLE IF NOT EXISTS, so this
/// is safe to call from both onUpgrade and the beforeOpen backstop
/// (parallel-branch version-collision self-heal).
///
/// o2_percent / he_percent carry the same non-null defaults as dive_tanks
/// so a configuration cylinder is never in an "unset gas" state.
Future<void> _assertCylinderConfigSchema() async {
await customStatement('''
CREATE TABLE IF NOT EXISTS cylinder_configs (
id TEXT NOT NULL PRIMARY KEY,
diver_id TEXT REFERENCES divers (id),
equipment_id TEXT REFERENCES equipment (id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
hlc TEXT
)
''');
await customStatement('''
CREATE TABLE IF NOT EXISTS cylinder_config_items (
id TEXT NOT NULL PRIMARY KEY,
config_id TEXT NOT NULL
REFERENCES cylinder_configs (id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
label TEXT,
tank_role TEXT NOT NULL,
volume_l REAL,
working_pressure_bar REAL,
tank_material TEXT,
o2_percent REAL NOT NULL DEFAULT 21.0,
he_percent REAL NOT NULL DEFAULT 0.0,
default_start_pressure_bar REAL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
hlc TEXT
)
''');
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_cylinder_configs_equipment '
'ON cylinder_configs (equipment_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_cylinder_config_items_config '
'ON cylinder_config_items (config_id)',
);
}

/// Test hook: re-assert the v139 tables on demand so tests can prove the
/// stranded-database self-heal path is idempotent.
Future<void> assertCylinderConfigSchemaForTest() =>
_assertCylinderConfigSchema();

/// v127: pre-dive checklist tables. Migrator.createTable is IF NOT EXISTS,
/// so this is safe to call from both onUpgrade and the beforeOpen backstop
/// (parallel-branch version-collision self-heal).
Expand Down Expand Up @@ -7161,6 +7285,10 @@ class AppDatabase extends _$AppDatabase {
await _clearGeneratedWeatherDescriptions();
}
if (from < 137) await reportProgress();
if (from < 139) {
await _assertCylinderConfigSchema();
await reportProgress();
}
},
beforeOpen: (details) async {
// Enable foreign keys
Expand Down Expand Up @@ -7269,6 +7397,11 @@ class AppDatabase extends _$AppDatabase {
// v135 backstop: re-assert color accent toggle columns.
await _assertAccentColorSettingsColumns();

// v139 backstop: re-assert the cylinder configuration tables. A
// database stranded at any lower version by a parallel-branch
// version collision self-heals here.
await _assertCylinderConfigSchema();

// Built-in dive types are reference data: identical on every device and
// undeletable through DiveTypeRepository. Nothing else restores them --
// the seed runs only in onCreate and the one-shot v93 step -- yet a
Expand Down
25 changes: 25 additions & 0 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import 'package:submersion/features/dive_sites/presentation/pages/site_match_rev
import 'package:submersion/features/equipment/presentation/pages/equipment_list_page.dart';
import 'package:submersion/features/equipment/presentation/pages/equipment_detail_page.dart';
import 'package:submersion/features/equipment/presentation/pages/equipment_edit_page.dart';
import 'package:submersion/features/cylinder_configs/presentation/pages/cylinder_config_edit_page.dart';
import 'package:submersion/features/cylinder_configs/presentation/pages/cylinder_config_list_page.dart';
import 'package:submersion/features/equipment/presentation/pages/equipment_set_list_page.dart';
import 'package:submersion/features/equipment/presentation/pages/service_kind_list_page.dart';
import 'package:submersion/features/equipment/presentation/pages/equipment_set_detail_page.dart';
Expand Down Expand Up @@ -510,6 +512,29 @@ final appRouterProvider = Provider<GoRouter>((ref) {
name: 'manageServiceTypes',
builder: (context, state) => const ServiceKindListPage(),
),
// Must precede the ':equipmentId' catch-all below, which would
// otherwise swallow 'cylinder-configs' as an equipment id.
GoRoute(
path: 'cylinder-configs',
name: 'cylinderConfigs',
builder: (context, state) => const CylinderConfigListPage(),
routes: [
GoRoute(
path: 'new',
name: 'newCylinderConfig',
builder: (context, state) => CylinderConfigEditPage(
equipmentId: state.uri.queryParameters['equipmentId'],
),
),
GoRoute(
path: ':configId',
name: 'cylinderConfigEdit',
builder: (context, state) => CylinderConfigEditPage(
configId: state.pathParameters['configId'],
),
),
],
),
GoRoute(
path: ':equipmentId',
name: 'equipmentDetail',
Expand Down
Loading