Skip to content
Merged
42 changes: 41 additions & 1 deletion lib/core/database/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,14 @@ class EquipmentSets extends Table {
/// layer, mirroring DiverRepository.setDefaultDiver.
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();

/// Whether this set is auto-applied to a dive whose computer is a member
/// of it (issue #1020), e.g. a CCR rig set that bundles the controller
/// with drysuit and tec fins. Opt-in per set, off by default: unlike
/// [isDefault] this has no diver-wide mutual exclusion, several sets can
/// have it on at once.
BoolColumn get autoApplyOnComputerImport =>
boolean().withDefault(const Constant(false))();

/// Hybrid Logical Clock for cross-device conflict resolution
/// (nullable: rows written before HLC rollout fall back to updatedAt).
TextColumn get hlc => text().nullable()();
Expand Down Expand Up @@ -4208,7 +4216,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 = 219;
static const int currentSchemaVersion = 220;

Comment thread
alpheios-one marked this conversation as resolved.
/// The oldest schema whose reader can apply this build's sync payloads
/// without loss or misinterpretation (the compatibility floor).
Expand Down Expand Up @@ -4819,6 +4827,10 @@ class AppDatabase extends _$AppDatabase {
// (equipment_id, tag_id) unique index. Additive only, so the
// compatibility floor stays.
219,
// v220: equipment_sets.auto_apply_on_computer_import (issue #1020).
// Additive column, default off. Renumbered from 219: #1964 (equipment
// tags) shipped first and claimed it.
220,
];

/// Idempotent DDL for the v106 connector-suggestion columns (Lightroom
Expand Down Expand Up @@ -6272,6 +6284,25 @@ class AppDatabase extends _$AppDatabase {
}
}

/// v220: equipment_sets.auto_apply_on_computer_import (issue #1020).
/// Additive column, default off, so pre-existing sets keep today's
/// behavior until a diver opts in. Idempotent, so it is safe to call from
/// both onUpgrade and the beforeOpen backstop.
Future<void> _assertEquipmentSetComputerAutoApplyColumn() async {
final cols = await customSelect(
"PRAGMA table_info('equipment_sets')",
).get();
if (cols.isEmpty) return;
final names = cols.map((c) => c.read<String>('name')).toSet();
if (!names.contains('auto_apply_on_computer_import')) {
await customStatement(
'ALTER TABLE equipment_sets '
'ADD COLUMN auto_apply_on_computer_import INTEGER NOT NULL '
'DEFAULT 0',
);
}
}

/// v218: diver_settings.site_detail_sections and site_detail_layout (issue
/// #1884). Idempotent, so it is safe to call from both onUpgrade and the
/// beforeOpen backstop, and a no-op when the table does not exist yet.
Expand Down Expand Up @@ -12256,8 +12287,17 @@ class AppDatabase extends _$AppDatabase {
await _assertEquipmentTagSchema();
}
if (from < 219) await reportProgress();
// v220: equipment_sets.auto_apply_on_computer_import (issue #1020).
// Column-only rung, no backfill: null/0 reads back as off.
if (from < 220) {
await _assertEquipmentSetComputerAutoApplyColumn();
}
if (from < 220) await reportProgress();
},
beforeOpen: (details) async {
// v220 backstop: the computer-set auto-apply opt-in column.
await _assertEquipmentSetComputerAutoApplyColumn();

// v217 and v219 backstop: the tag scope flags.
await _assertTagScopeColumns();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:submersion/features/dive_import/domain/resyncable_import_formats
import 'package:submersion/features/dive_log/domain/services/dive_altitude_enricher.dart';
import 'package:submersion/features/equipment/data/repositories/equipment_observation_repository.dart';
import 'package:submersion/features/equipment/data/services/dive_computer_gear_linker.dart';
import 'package:submersion/features/equipment/data/services/equipment_set_for_computer_linker.dart';
import 'package:submersion/features/equipment/data/services/dive_equipment_defaulter.dart';
import 'package:submersion/features/equipment/domain/entities/equipment_observation.dart';
import 'package:submersion/features/pre_dive/data/services/checklist_dive_linker.dart';
Expand Down Expand Up @@ -2595,6 +2596,11 @@ class UddfEntityImporter {
// that already has equipment, so linking first would suppress the
// diver's default and geofenced sets.
await DiveComputerGearLinker().linkComputerGearForDive(diveId: dive.id);
// Apply every equipment set that lists this computer as a member
// (issue #1020). Additive, independent of the defaulter above.
await EquipmentSetForComputerLinker().linkComputerSetsForDive(
diveId: dive.id,
);
importedDiveIds.add(diveId);
diveIdByIndex[i] = diveId;
final sourceUuid = diveData['sourceUuid'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:submersion/core/providers/provider.dart';
import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart';
import 'package:submersion/features/dive_log/domain/services/dive_altitude_enricher.dart';
import 'package:submersion/features/equipment/data/services/dive_computer_gear_linker.dart';
import 'package:submersion/features/equipment/data/services/equipment_set_for_computer_linker.dart';
import 'package:submersion/features/equipment/data/services/dive_equipment_defaulter.dart';
import 'package:submersion/features/pre_dive/data/services/checklist_dive_linker.dart';
import 'package:submersion/features/dive_import/data/services/fit_parser_service.dart';
Expand Down Expand Up @@ -401,6 +402,11 @@ class DiveImportNotifier extends StateNotifier<DiveImportState> {
// that already has equipment, so linking first would suppress the
// diver's default and geofenced sets.
await DiveComputerGearLinker().linkComputerGearForDive(diveId: dive.id);
// Apply every equipment set that lists this computer as a member
// (issue #1020). Additive, independent of the defaulter above.
await EquipmentSetForComputerLinker().linkComputerSetsForDive(
diveId: dive.id,
);
imported++;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import 'package:submersion/features/dive_log/domain/services/bottom_time_calcula
import 'package:submersion/features/dive_log/domain/services/dive_altitude_enricher.dart';
import 'package:submersion/features/dive_log/domain/services/tank_pressure_series.dart';
import 'package:submersion/features/equipment/data/services/dive_computer_gear_linker.dart';
import 'package:submersion/features/equipment/data/services/equipment_set_for_computer_linker.dart';
import 'package:submersion/features/equipment/data/services/dive_computer_gear_resolver.dart';
import 'package:submersion/features/equipment/data/services/dive_equipment_defaulter.dart';
import 'package:submersion/features/pre_dive/data/services/checklist_dive_linker.dart';
Expand Down Expand Up @@ -1411,6 +1412,13 @@ class DiveComputerRepository {
// diver's default and geofenced sets.
await DiveComputerGearLinker().linkComputerGearForDive(diveId: diveId);

// Apply every equipment set that lists this computer as a member
// (issue #1020), e.g. a CCR rig set that bundles the controller with
// drysuit and tec fins. Additive, independent of the defaulter above.
await EquipmentSetForComputerLinker().linkComputerSetsForDive(
diveId: diveId,
);

// Auto-link a pre-dive checklist session started shortly before
// this dive's entry time.
await ChecklistDiveLinker().autoLinkForDive(
Expand Down Expand Up @@ -1761,6 +1769,11 @@ class DiveComputerRepository {
// existing dive, but the computer did log it. Idempotent through
// insertOnConflictUpdate.
await DiveComputerGearLinker().linkComputerGearForDive(diveId: diveId);
// Apply every equipment set that lists this computer as a member
// (issue #1020). Additive, independent of the defaulter above.
await EquipmentSetForComputerLinker().linkComputerSetsForDive(
diveId: diveId,
);
}

// Note: Computer stats (incrementDiveCount, updateLastDownload) are
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ class EquipmentSetRepository {
diverId: Value(set.diverId),
name: Value(set.name),
description: Value(set.description),
autoApplyOnComputerImport: Value(set.autoApplyOnComputerImport),
createdAt: Value(now),
updatedAt: Value(now),
),
Expand Down Expand Up @@ -217,6 +218,7 @@ class EquipmentSetRepository {
EquipmentSetsCompanion(
name: Value(set.name),
description: Value(set.description),
autoApplyOnComputerImport: Value(set.autoApplyOnComputerImport),
updatedAt: Value(now),
),
);
Expand Down Expand Up @@ -537,6 +539,7 @@ class EquipmentSetRepository {
description: row.description,
equipmentIds: equipmentIds,
isDefault: row.isDefault,
autoApplyOnComputerImport: row.autoApplyOnComputerImport,
createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt),
updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,7 @@ class DiveComputerGearLinker {
Future<bool> linkComputerGearForDive({required String diveId}) async {
if (DatabaseService.instance.databaseOrNull == null) return false;
try {
final computerIds = await _computerIdsForDive(diveId);
if (computerIds.isEmpty) return false;

final rows = await (_db.select(
_db.diveComputers,
)..where((t) => t.id.isIn(computerIds))).get();
final equipmentIds = rows
.map((r) => r.equipmentId)
.whereType<String>()
.where((id) => id.isNotEmpty)
.toSet()
.toList();
final equipmentIds = await gearTwinEquipmentIdsForDive(diveId);
if (equipmentIds.isEmpty) return false;

await _dives.bulkAddEquipment([diveId], equipmentIds);
Expand All @@ -59,6 +48,24 @@ class DiveComputerGearLinker {
}
}

/// Gear-twin equipment ids of every computer that logged [diveId], for
/// `EquipmentSetForComputerLinker` (issue #1020) to resolve set membership
/// from without duplicating the computer/twin lookup.
Future<List<String>> gearTwinEquipmentIdsForDive(String diveId) async {
final computerIds = await _computerIdsForDive(diveId);
if (computerIds.isEmpty) return [];

final rows = await (_db.select(
_db.diveComputers,
)..where((t) => t.id.isIn(computerIds))).get();
return rows
.map((r) => r.equipmentId)
.whereType<String>()
.where((id) => id.isNotEmpty)
.toSet()
.toList();
}

/// Every computer that logged [diveId].
///
/// Deliberately NOT `DiveComputerRepository.getComputerIdsForDive`, which
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import 'package:drift/drift.dart';

import 'package:submersion/core/database/database.dart';
import 'package:submersion/core/services/database_service.dart';
import 'package:submersion/core/services/sync/sync_event_bus.dart';
import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart';
import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart';
import 'package:submersion/features/equipment/data/services/dive_computer_gear_linker.dart';

/// Applies every equipment set that lists a dive's computer as a member and
/// has opted in to `autoApplyOnComputerImport` (issue #1020).
///
/// A diver who keeps a computer permanently paired with the rest of a rig
/// (e.g. a CCR controller with drysuit, tec fins, bailout) models that by
/// adding the computer's gear twin to an `EquipmentSet` alongside the other
/// items, then turning the set's "apply when this computer is imported"
/// switch on -- off by default, so existing sets are unaffected until a
/// diver opts in. Once the computer that logged this dive is known, this
/// service looks up every opted-in set containing that computer's gear-twin
/// equipment id and adds that set's full roster.
///
/// Runs at every seam `DiveComputerGearLinker` runs at, after it. Unlike
/// `DiveEquipmentDefaulter` this is NOT gated on the dive being empty and NOT
/// mutually exclusive with a geofenced/default set: a diver can have both a
/// location-based set and a computer-based set match the same dive, in which
/// case both are applied (the issue itself accepts this as a case the diver
/// may need to correct manually). Several sets can list the same computer;
/// all of them are applied. `bulkAddEquipment` is idempotent per equipment
/// id and only fills a still-null `viaSetId`, so an overlapping or repeated
/// application never duplicates a row or steals another set's provenance.
///
/// Best-effort: any failure is swallowed so this can never abort a download
/// or import that has already persisted the dive.
class EquipmentSetForComputerLinker {
EquipmentSetForComputerLinker({
DiveComputerGearLinker? gearLinker,
EquipmentSetRepository? equipmentSetRepository,
DiveRepository? diveRepository,
}) : _gearLinker = gearLinker ?? DiveComputerGearLinker(),
_sets = equipmentSetRepository ?? EquipmentSetRepository(),
_dives = diveRepository ?? DiveRepository();

final DiveComputerGearLinker _gearLinker;
final EquipmentSetRepository _sets;
final DiveRepository _dives;

AppDatabase get _db => DatabaseService.instance.database;

/// Returns true when at least one set was applied.
///
/// Scoped to the dive's own diver, same as `DiveEquipmentDefaulter`: a
/// dive computer can be shared across diver profiles in one local
/// database (e.g. buddies syncing one install), and without this a set
/// another diver built around that same computer would silently attach
/// its unrelated gear to this diver's dive. A dive with no diver (owner-
/// less) is skipped entirely rather than crossing diver scopes.
Future<bool> linkComputerSetsForDive({required String diveId}) async {
if (DatabaseService.instance.databaseOrNull == null) return false;
// Declared outside the try so a failure partway through the loop below
// still reports (and notifies sync about) whichever sets already wrote
// successfully, instead of masking a real DB change as "nothing happened".
var appliedAny = false;
try {
final computerEquipmentIds = await _gearLinker
.gearTwinEquipmentIdsForDive(diveId);
if (computerEquipmentIds.isEmpty) return false;
Comment thread
alpheios-one marked this conversation as resolved.

final dive = await (_db.select(
_db.dives,
)..where((t) => t.id.equals(diveId))).getSingleOrNull();
final diverId = dive?.diverId;
if (diverId == null) return false;

// One joined query for "opted-in sets, owned by this diver, that
// contain one of these computers" rather than a set-membership
// lookup followed by a separate opt-in filter.
final placeholders = computerEquipmentIds.map((_) => '?').join(',');
final rows = await _db
.customSelect(
'SELECT DISTINCT s.id FROM equipment_sets s '
'JOIN equipment_set_items i ON i.set_id = s.id '
'WHERE i.equipment_id IN ($placeholders) '
'AND s.auto_apply_on_computer_import = 1 '
'AND s.diver_id = ?',
variables: [
for (final id in computerEquipmentIds) Variable<String>(id),
Variable<String>(diverId),
],
readsFrom: {_db.equipmentSets, _db.equipmentSetItems},
)
.get();
final setIds = rows.map((row) => row.read<String>('id')).toSet();
if (setIds.isEmpty) return false;

for (final setId in setIds) {
final setEquipmentIds = await _sets.getEquipmentIdsInSet(setId);
if (setEquipmentIds.isEmpty) continue;
await _dives.bulkAddEquipment(
[diveId],
setEquipmentIds,
viaSetId: setId,
);
appliedAny = true;
}
return appliedAny;
} catch (_) {
// Best-effort: never let this fail the dive operation. appliedAny may
// already be true if an earlier set in the loop wrote successfully
// before a later one threw; report that partial success rather than
// masking it as false.
return appliedAny;
} finally {
// Runs once regardless of which path returned, so a partial success
// followed by a thrown exception still notifies sync about the sets
// that did get written.
if (appliedAny) SyncEventBus.notifyLocalChange();
}
}
}
9 changes: 9 additions & 0 deletions lib/features/equipment/domain/entities/equipment_set.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ class EquipmentSet extends Equatable {
final List<String> equipmentIds;
final List<EquipmentItem>? items; // Populated when fetched with items
final bool isDefault;

/// Whether this set is auto-applied to a dive whose computer is a member
/// of it (issue #1020). Opt-in, off by default.
final bool autoApplyOnComputerImport;
final List<EquipmentSetGeofence> geofences; // Populated when fetched
final DateTime createdAt;
final DateTime updatedAt;
Expand All @@ -24,6 +28,7 @@ class EquipmentSet extends Equatable {
this.equipmentIds = const [],
this.items,
this.isDefault = false,
this.autoApplyOnComputerImport = false,
this.geofences = const [],
required this.createdAt,
required this.updatedAt,
Expand All @@ -45,6 +50,7 @@ class EquipmentSet extends Equatable {
List<String>? equipmentIds,
List<EquipmentItem>? items,
bool? isDefault,
bool? autoApplyOnComputerImport,
List<EquipmentSetGeofence>? geofences,
DateTime? createdAt,
DateTime? updatedAt,
Expand All @@ -57,6 +63,8 @@ class EquipmentSet extends Equatable {
equipmentIds: equipmentIds ?? this.equipmentIds,
items: items ?? this.items,
isDefault: isDefault ?? this.isDefault,
autoApplyOnComputerImport:
autoApplyOnComputerImport ?? this.autoApplyOnComputerImport,
geofences: geofences ?? this.geofences,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
Expand All @@ -71,6 +79,7 @@ class EquipmentSet extends Equatable {
description,
equipmentIds,
isDefault,
autoApplyOnComputerImport,
geofences,
createdAt,
updatedAt,
Expand Down
Loading
Loading