From de52d3f682a9c92aa84aaeefc5b560f00bb876d4 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:21:11 +0200 Subject: [PATCH 1/8] Apply equipment sets that list a dive computer as a member (#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. This adds EquipmentSetForComputerLinker, which runs at every seam DiveComputerGearLinker runs at and applies the full roster of every set that lists the imported computer, additively and independent of the diver's default/geofenced set. Refs #1020 --- .../data/services/uddf_entity_importer.dart | 6 + .../providers/dive_import_providers.dart | 6 + .../dive_computer_repository_impl.dart | 11 + .../services/dive_computer_gear_linker.dart | 31 ++- .../equipment_set_for_computer_linker.dart | 77 ++++++ .../data/adapters/healthkit_adapter.dart | 6 + ...quipment_set_for_computer_linker_test.dart | 231 ++++++++++++++++++ 7 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 lib/features/equipment/data/services/equipment_set_for_computer_linker.dart create mode 100644 test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index 36b2de62c4..ecbe28f2e7 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -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'; @@ -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']; diff --git a/lib/features/dive_import/presentation/providers/dive_import_providers.dart b/lib/features/dive_import/presentation/providers/dive_import_providers.dart index 0b64b0f30d..61a1a8f2d9 100644 --- a/lib/features/dive_import/presentation/providers/dive_import_providers.dart +++ b/lib/features/dive_import/presentation/providers/dive_import_providers.dart @@ -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'; @@ -401,6 +402,11 @@ class DiveImportNotifier extends StateNotifier { // 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++; } diff --git a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart index 6ff2fdf8fa..efcdd00fe6 100644 --- a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart @@ -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'; @@ -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( @@ -1761,6 +1769,9 @@ class DiveComputerRepository { // existing dive, but the computer did log it. Idempotent through // insertOnConflictUpdate. await DiveComputerGearLinker().linkComputerGearForDive(diveId: diveId); + await EquipmentSetForComputerLinker().linkComputerSetsForDive( + diveId: diveId, + ); } // Note: Computer stats (incrementDiveCount, updateLastDownload) are diff --git a/lib/features/equipment/data/services/dive_computer_gear_linker.dart b/lib/features/equipment/data/services/dive_computer_gear_linker.dart index f8c76c6aa1..f3e5dc14b6 100644 --- a/lib/features/equipment/data/services/dive_computer_gear_linker.dart +++ b/lib/features/equipment/data/services/dive_computer_gear_linker.dart @@ -36,18 +36,7 @@ class DiveComputerGearLinker { Future 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() - .where((id) => id.isNotEmpty) - .toSet() - .toList(); + final equipmentIds = await gearTwinEquipmentIdsForDive(diveId); if (equipmentIds.isEmpty) return false; await _dives.bulkAddEquipment([diveId], equipmentIds); @@ -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> 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() + .where((id) => id.isNotEmpty) + .toSet() + .toList(); + } + /// Every computer that logged [diveId]. /// /// Deliberately NOT `DiveComputerRepository.getComputerIdsForDive`, which diff --git a/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart new file mode 100644 index 0000000000..d9ed88b18b --- /dev/null +++ b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart @@ -0,0 +1,77 @@ +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 +/// (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. Once the computer that logged this dive is known, this service +/// looks up every 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. + Future linkComputerSetsForDive({required String diveId}) async { + if (DatabaseService.instance.databaseOrNull == null) return false; + try { + final computerEquipmentIds = await _gearLinker + .gearTwinEquipmentIdsForDive(diveId); + if (computerEquipmentIds.isEmpty) return false; + + final memberRows = await (_db.select( + _db.equipmentSetItems, + )..where((t) => t.equipmentId.isIn(computerEquipmentIds))).get(); + final setIds = memberRows.map((r) => r.setId).toSet(); + if (setIds.isEmpty) return false; + + var appliedAny = 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; + } + if (appliedAny) SyncEventBus.notifyLocalChange(); + return appliedAny; + } catch (_) { + // Best-effort: never let this fail the dive operation. + return false; + } + } +} diff --git a/lib/features/import_wizard/data/adapters/healthkit_adapter.dart b/lib/features/import_wizard/data/adapters/healthkit_adapter.dart index bc8bef25b1..9a903f0de8 100644 --- a/lib/features/import_wizard/data/adapters/healthkit_adapter.dart +++ b/lib/features/import_wizard/data/adapters/healthkit_adapter.dart @@ -7,6 +7,7 @@ import 'package:submersion/features/data_quality/data/services/quality_scan_serv import 'package:submersion/features/dive_import/domain/entities/imported_dive.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/equipment/data/services/sensor_summary_scheduler.dart'; import 'package:submersion/features/pre_dive/data/services/checklist_dive_linker.dart'; @@ -304,6 +305,11 @@ class HealthKitAdapter implements ImportSourceAdapter { // 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++; importedDiveIds.add(dive.id); diff --git a/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart new file mode 100644 index 0000000000..e3ab6b5b04 --- /dev/null +++ b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart @@ -0,0 +1,231 @@ +import 'package:drift/drift.dart' hide isNull; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/equipment/data/services/equipment_set_for_computer_linker.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late EquipmentSetForComputerLinker linker; + + setUp(() async { + db = await setUpTestDatabase(); + await db.customStatement('PRAGMA foreign_keys = OFF'); + linker = EquipmentSetForComputerLinker(); + }); + tearDown(tearDownTestDatabase); + + Future insertGear(String id, {String type = 'computer'}) async { + final t = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.equipment) + .insert( + EquipmentCompanion.insert( + id: id, + name: id, + type: type, + createdAt: t, + updatedAt: t, + ), + ); + } + + Future insertComputer(String id, {String? equipmentId}) async { + final t = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion.insert( + id: id, + name: id, + equipmentId: Value(equipmentId), + createdAt: t, + updatedAt: t, + ), + ); + } + + Future linkSource(String diveId, String computerId) async { + await db.customStatement( + 'INSERT INTO dive_data_sources (id, dive_id, computer_id, is_primary, ' + 'imported_at, created_at) VALUES (?, ?, ?, 1, 1, 1)', + ['src-$diveId-$computerId', diveId, computerId], + ); + } + + Future insertSet(String id, {String? diverId}) async { + final t = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.equipmentSets) + .insert( + EquipmentSetsCompanion.insert( + id: id, + diverId: Value(diverId), + name: id, + createdAt: t, + updatedAt: t, + ), + ); + } + + Future addToSet(String setId, String equipmentId) async { + await db + .into(db.equipmentSetItems) + .insert( + EquipmentSetItemsCompanion.insert( + setId: setId, + equipmentId: equipmentId, + ), + ); + } + + Future> equipmentOn(String diveId) async { + final rows = await (db.select( + db.diveEquipment, + )..where((t) => t.diveId.equals(diveId))).get(); + return rows.map((r) => r.equipmentId).toSet(); + } + + test('applies the set that lists the dive computer as a member', () async { + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertGear('gear-fins', type: 'fins'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-ccr'); + await addToSet('set-ccr', 'gear-computer'); + await addToSet('set-ccr', 'gear-drysuit'); + await addToSet('set-ccr', 'gear-fins'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); + expect(await equipmentOn('dive1'), { + 'gear-computer', + 'gear-drysuit', + 'gear-fins', + }); + }); + + test('applies every set that lists the computer, additively', () async { + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertGear('gear-bailout', type: 'cylinder'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-rig'); + await addToSet('set-rig', 'gear-computer'); + await addToSet('set-rig', 'gear-drysuit'); + await insertSet('set-bailout'); + await addToSet('set-bailout', 'gear-computer'); + await addToSet('set-bailout', 'gear-bailout'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); + expect(await equipmentOn('dive1'), { + 'gear-computer', + 'gear-drysuit', + 'gear-bailout', + }); + }); + + test('adds to existing equipment rather than replacing it', () async { + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-ccr'); + await addToSet('set-ccr', 'gear-computer'); + await addToSet('set-ccr', 'gear-drysuit'); + await linkSource('dive1', 'c1'); + await db + .into(db.diveEquipment) + .insert( + DiveEquipmentCompanion.insert(diveId: 'dive1', equipmentId: 'a-bcd'), + ); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); + expect(await equipmentOn('dive1'), { + 'a-bcd', + 'gear-computer', + 'gear-drysuit', + }); + }); + + test( + 'does not remove an item from a set applied separately by the defaulter', + () async { + // The defaulter tags rows with its own set's id; a later, additive call + // from this linker must not steal that provenance for an item both + // sets happen to share (GearExpander only fills a null viaSetId). + await insertGear('gear-computer'); + await insertGear('gear-fins', type: 'fins'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-geo'); + await addToSet('set-geo', 'gear-fins'); + await insertSet('set-ccr'); + await addToSet('set-ccr', 'gear-computer'); + await addToSet('set-ccr', 'gear-fins'); + await linkSource('dive1', 'c1'); + await db + .into(db.diveEquipment) + .insert( + DiveEquipmentCompanion.insert( + diveId: 'dive1', + equipmentId: 'gear-fins', + viaSetId: const Value('set-geo'), + ), + ); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); + final finsRow = await (db.select( + db.diveEquipment, + )..where((t) => t.equipmentId.equals('gear-fins'))).getSingle(); + expect(finsRow.viaSetId, 'set-geo'); + }, + ); + + test('is a no-op when the computer belongs to no set', () async { + await insertGear('gear-computer'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + expect(await equipmentOn('dive1'), isEmpty); + }); + + test('never applies a set for a computer whose twin was deleted', () async { + await insertComputer('c1'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + expect(await equipmentOn('dive1'), isEmpty); + }); + + test('is a no-op for a dive with no registered computer', () async { + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + expect(await equipmentOn('dive1'), isEmpty); + }); + + test('is idempotent', () async { + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-ccr'); + await addToSet('set-ccr', 'gear-computer'); + await addToSet('set-ccr', 'gear-drysuit'); + await linkSource('dive1', 'c1'); + + await linker.linkComputerSetsForDive(diveId: 'dive1'); + await linker.linkComputerSetsForDive(diveId: 'dive1'); + + expect(await equipmentOn('dive1'), {'gear-computer', 'gear-drysuit'}); + }); + + test('returns false instead of throwing when the read fails', () async { + await insertGear('gear-computer'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await linkSource('dive1', 'c1'); + await db.customStatement('DROP TABLE dive_data_sources'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + }); +} From 24f4f6451973cc824c90259597a0840798bbd26f Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:28:47 +0200 Subject: [PATCH 2/8] Make computer-set auto-apply opt-in per set (#1020) EquipmentSetForComputerLinker applied any set that listed the imported computer unconditionally, which could surprise a diver with gear they did not expect on a dive. Adds a per-set switch, autoApplyOnComputerImport, off by default: the linker now only applies a set that has explicitly opted in. Refs #1020 --- lib/core/database/database.dart | 42 +++++++++++++++++- .../equipment_set_repository_impl.dart | 3 ++ .../equipment_set_for_computer_linker.dart | 26 ++++++++--- .../domain/entities/equipment_set.dart | 9 ++++ .../pages/equipment_set_edit_page.dart | 17 ++++++++ lib/l10n/arb/app_de.arb | 2 + lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_localizations.dart | 12 ++++++ lib/l10n/arb/app_localizations_ar.dart | 8 ++++ lib/l10n/arb/app_localizations_de.dart | 8 ++++ lib/l10n/arb/app_localizations_en.dart | 8 ++++ lib/l10n/arb/app_localizations_es.dart | 8 ++++ lib/l10n/arb/app_localizations_fr.dart | 8 ++++ lib/l10n/arb/app_localizations_he.dart | 8 ++++ lib/l10n/arb/app_localizations_hu.dart | 8 ++++ lib/l10n/arb/app_localizations_it.dart | 8 ++++ lib/l10n/arb/app_localizations_nl.dart | 8 ++++ lib/l10n/arb/app_localizations_pt.dart | 8 ++++ lib/l10n/arb/app_localizations_zh.dart | 8 ++++ ...quipment_set_for_computer_linker_test.dart | 43 ++++++++++++++++++- 20 files changed, 236 insertions(+), 8 deletions(-) diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index bcf19f444a..0df1b485d0 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -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()(); @@ -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; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -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 @@ -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 _assertEquipmentSetComputerAutoApplyColumn() async { + final cols = await customSelect( + "PRAGMA table_info('equipment_sets')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('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. @@ -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(); diff --git a/lib/features/equipment/data/repositories/equipment_set_repository_impl.dart b/lib/features/equipment/data/repositories/equipment_set_repository_impl.dart index 9929c2eaa1..f5e0b2e247 100644 --- a/lib/features/equipment/data/repositories/equipment_set_repository_impl.dart +++ b/lib/features/equipment/data/repositories/equipment_set_repository_impl.dart @@ -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), ), @@ -217,6 +218,7 @@ class EquipmentSetRepository { EquipmentSetsCompanion( name: Value(set.name), description: Value(set.description), + autoApplyOnComputerImport: Value(set.autoApplyOnComputerImport), updatedAt: Value(now), ), ); @@ -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), ); diff --git a/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart index d9ed88b18b..cace6035d3 100644 --- a/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart +++ b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart @@ -1,3 +1,5 @@ +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'; @@ -5,15 +7,17 @@ import 'package:submersion/features/dive_log/data/repositories/dive_repository_i 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 -/// (issue #1020). +/// 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. Once the computer that logged this dive is known, this service -/// looks up every set containing that computer's gear-twin equipment id and -/// adds that set's full roster. +/// 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 @@ -53,7 +57,17 @@ class EquipmentSetForComputerLinker { final memberRows = await (_db.select( _db.equipmentSetItems, )..where((t) => t.equipmentId.isIn(computerEquipmentIds))).get(); - final setIds = memberRows.map((r) => r.setId).toSet(); + final candidateSetIds = memberRows.map((r) => r.setId).toSet(); + if (candidateSetIds.isEmpty) return false; + + final optedInRows = + await (_db.select(_db.equipmentSets)..where( + (t) => + t.id.isIn(candidateSetIds) & + t.autoApplyOnComputerImport.equals(true), + )) + .get(); + final setIds = optedInRows.map((r) => r.id).toSet(); if (setIds.isEmpty) return false; var appliedAny = false; diff --git a/lib/features/equipment/domain/entities/equipment_set.dart b/lib/features/equipment/domain/entities/equipment_set.dart index b9a8aa2757..cd4c638f62 100644 --- a/lib/features/equipment/domain/entities/equipment_set.dart +++ b/lib/features/equipment/domain/entities/equipment_set.dart @@ -12,6 +12,10 @@ class EquipmentSet extends Equatable { final List equipmentIds; final List? 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 geofences; // Populated when fetched final DateTime createdAt; final DateTime updatedAt; @@ -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, @@ -45,6 +50,7 @@ class EquipmentSet extends Equatable { List? equipmentIds, List? items, bool? isDefault, + bool? autoApplyOnComputerImport, List? geofences, DateTime? createdAt, DateTime? updatedAt, @@ -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, @@ -71,6 +79,7 @@ class EquipmentSet extends Equatable { description, equipmentIds, isDefault, + autoApplyOnComputerImport, geofences, createdAt, updatedAt, diff --git a/lib/features/equipment/presentation/pages/equipment_set_edit_page.dart b/lib/features/equipment/presentation/pages/equipment_set_edit_page.dart index 29e9f540a3..b55206cd76 100644 --- a/lib/features/equipment/presentation/pages/equipment_set_edit_page.dart +++ b/lib/features/equipment/presentation/pages/equipment_set_edit_page.dart @@ -40,6 +40,7 @@ class _EquipmentSetEditPageState extends ConsumerState { bool _isLoading = false; bool _isInitialized = false; bool _isDefault = false; + bool _autoApplyOnComputerImport = false; List _geofences = []; @override @@ -57,6 +58,7 @@ class _EquipmentSetEditPageState extends ConsumerState { _descriptionController.text = set.description; _selectedEquipmentIds.addAll(set.equipmentIds); _isDefault = set.isDefault; + _autoApplyOnComputerImport = set.autoApplyOnComputerImport; _geofences = List.of(set.geofences); } @@ -185,6 +187,20 @@ class _EquipmentSetEditPageState extends ConsumerState { value: _isDefault, onChanged: (v) => setState(() => _isDefault = v), ), + const SizedBox(height: 8), + + // Auto-apply when this set's computer is imported (issue #1020) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + context.l10n.equipment_setEdit_computerAutoApplySwitch_title, + ), + subtitle: Text( + context.l10n.equipment_setEdit_computerAutoApplySwitch_subtitle, + ), + value: _autoApplyOnComputerImport, + onChanged: (v) => setState(() => _autoApplyOnComputerImport = v), + ), const SizedBox(height: 16), // Geofences @@ -463,6 +479,7 @@ class _EquipmentSetEditPageState extends ConsumerState { name: _nameController.text.trim(), description: _descriptionController.text.trim(), equipmentIds: _selectedEquipmentIds.toList(), + autoApplyOnComputerImport: _autoApplyOnComputerImport, createdAt: existingSet?.createdAt ?? DateTime.now(), updatedAt: DateTime.now(), ); diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index eea8138372..ff318ca9b2 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -205,6 +205,8 @@ "equipment_components_title": "Komponenten", "equipment_setEdit_defaultSwitch_title": "Standard-Set", "equipment_setEdit_defaultSwitch_subtitle": "Wird automatisch auf neue Tauchgänge ohne Ausrüstung angewendet", + "equipment_setEdit_computerAutoApplySwitch_title": "Anwenden, wenn dieser Tauchcomputer importiert wird", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "Enthält dieses Set einen Tauchcomputer, wird das ganze Set automatisch zu einem von diesem heruntergeladenen oder importierten Tauchgang hinzugefügt", "equipment_setEdit_geofencesTitle": "Geofences", "equipment_setEdit_geofencesSubtitle": "Dieses Set automatisch für Tauchgänge in der Nähe dieser Orte vorschlagen", "equipment_setEdit_addGeofence": "Geofence hinzufügen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index c6dda41e19..f409214a04 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -78,6 +78,8 @@ "common_action_dismiss": "Dismiss", "equipment_setEdit_defaultSwitch_title": "Default set", "equipment_setEdit_defaultSwitch_subtitle": "Auto-applied to new dives that have no equipment yet", + "equipment_setEdit_computerAutoApplySwitch_title": "Apply when this set's computer is imported", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it", "equipment_setEdit_geofencesTitle": "Geofences", "equipment_setEdit_geofencesSubtitle": "Auto-suggest this set for dives near these locations", "equipment_setEdit_addGeofence": "Add geofence", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index e6bea1ff1f..d32e7d85aa 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -392,6 +392,18 @@ abstract class AppLocalizations { /// **'Auto-applied to new dives that have no equipment yet'** String get equipment_setEdit_defaultSwitch_subtitle; + /// No description provided for @equipment_setEdit_computerAutoApplySwitch_title. + /// + /// In en, this message translates to: + /// **'Apply when this set\'s computer is imported'** + String get equipment_setEdit_computerAutoApplySwitch_title; + + /// No description provided for @equipment_setEdit_computerAutoApplySwitch_subtitle. + /// + /// In en, this message translates to: + /// **'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'** + String get equipment_setEdit_computerAutoApplySwitch_subtitle; + /// No description provided for @equipment_setEdit_geofencesTitle. /// /// 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 547c04d064..339a76b86b 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -214,6 +214,14 @@ class AppLocalizationsAr extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'تُطبَّق تلقائيًا على الغطسات الجديدة التي لا تحتوي على معدات بعد'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'الأسوار الجغرافية'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 6e54b7b7ef..ca183e62dc 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -216,6 +216,14 @@ class AppLocalizationsDe extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Wird automatisch auf neue Tauchgänge ohne Ausrüstung angewendet'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Anwenden, wenn dieser Tauchcomputer importiert wird'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'Enthält dieses Set einen Tauchcomputer, wird das ganze Set automatisch zu einem von diesem heruntergeladenen oder importierten Tauchgang hinzugefügt'; + @override String get equipment_setEdit_geofencesTitle => 'Geofences'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 0f27932885..32344e63cf 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -214,6 +214,14 @@ class AppLocalizationsEn extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Auto-applied to new dives that have no equipment yet'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'Geofences'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 502a0ebacd..e636a09251 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -216,6 +216,14 @@ class AppLocalizationsEs extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Se aplica automáticamente a las inmersiones nuevas que aún no tienen equipo'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'Geocercas'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index fddc5e2ccb..8f1fa6ddfe 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -217,6 +217,14 @@ class AppLocalizationsFr extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Appliqué automatiquement aux nouvelles plongées sans équipement'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'Géorepères'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index e6a3152304..3f0ca57ec7 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -213,6 +213,14 @@ class AppLocalizationsHe extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'מוחלת אוטומטית על צלילות חדשות שאין בהן ציוד עדיין'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'גדרות גאוגרפיות'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 1022d1173f..4b5cdd7502 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -216,6 +216,14 @@ class AppLocalizationsHu extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Automatikusan alkalmazza az új, még felszerelés nélküli merülésekre'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'Geokerítések'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 087c20821f..4c0710d24f 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -217,6 +217,14 @@ class AppLocalizationsIt extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Applicato automaticamente alle nuove immersioni senza attrezzatura'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'Geofence'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 3ab0e6739b..402f88d8c3 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -214,6 +214,14 @@ class AppLocalizationsNl extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Automatisch toegepast op nieuwe duiken zonder uitrusting'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'Geofences'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 1bf689495f..eeb80006f9 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -216,6 +216,14 @@ class AppLocalizationsPt extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => 'Aplicado automaticamente a novos mergulhos que ainda não têm equipamento'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => 'Geocercas'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index dbb1923c2e..b534fb0e3b 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -204,6 +204,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String get equipment_setEdit_defaultSwitch_subtitle => '自动应用于尚无装备的新潜水'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => + 'Apply when this set\'s computer is imported'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + @override String get equipment_setEdit_geofencesTitle => '地理围栏'; diff --git a/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart index e3ab6b5b04..37801facb6 100644 --- a/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart +++ b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart @@ -55,7 +55,11 @@ void main() { ); } - Future insertSet(String id, {String? diverId}) async { + Future insertSet( + String id, { + String? diverId, + bool autoApplyOnComputerImport = true, + }) async { final t = DateTime.now().millisecondsSinceEpoch; await db .into(db.equipmentSets) @@ -64,6 +68,7 @@ void main() { id: id, diverId: Value(diverId), name: id, + autoApplyOnComputerImport: Value(autoApplyOnComputerImport), createdAt: t, updatedAt: t, ), @@ -183,6 +188,42 @@ void main() { }, ); + test( + 'is a no-op when the matching set has not opted in (default off)', + () async { + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-ccr', autoApplyOnComputerImport: false); + await addToSet('set-ccr', 'gear-computer'); + await addToSet('set-ccr', 'gear-drysuit'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + expect(await equipmentOn('dive1'), isEmpty); + }, + ); + + test( + 'applies only the opted-in set when another matching set has not', + () async { + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertGear('gear-bailout', type: 'cylinder'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-ccr', autoApplyOnComputerImport: true); + await addToSet('set-ccr', 'gear-computer'); + await addToSet('set-ccr', 'gear-drysuit'); + await insertSet('set-other', autoApplyOnComputerImport: false); + await addToSet('set-other', 'gear-computer'); + await addToSet('set-other', 'gear-bailout'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); + expect(await equipmentOn('dive1'), {'gear-computer', 'gear-drysuit'}); + }, + ); + test('is a no-op when the computer belongs to no set', () async { await insertGear('gear-computer'); await insertComputer('c1', equipmentId: 'gear-computer'); From 83e0d3bac6ea8d74b114480fecc26b513bd23c9f Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:29:18 +0200 Subject: [PATCH 3/8] Fix CI: v219 migration ladder, widget test, missing translations - database.dart: register 219 in migrationVersions (missed in the prior commit), so migration-progress step counts stay accurate. - migration_v218_site_detail_sections_test.dart: relax the "current schema version" assertion now that v219 exists, and stop asserting the exact post-upgrade version, since onUpgrade runs the whole span in one call. - migration_v219_equipment_set_computer_auto_apply_test.dart: new, following the same per-rung pattern as the v218 test. - equipment_set_edit_page_test.dart: the new computer-auto-apply switch made "the Default switch" finder ambiguous (now 2 SwitchListTiles), and pushed the equipment list further below the fold, out of the lazily-built ListView's initial viewport. - Added the two new ARB keys to all 9 remaining non-English locales (only en/de had them) to satisfy the ARB parity test. --- lib/l10n/arb/app_ar.arb | 2 + lib/l10n/arb/app_es.arb | 2 + lib/l10n/arb/app_fr.arb | 2 + lib/l10n/arb/app_he.arb | 2 + lib/l10n/arb/app_hu.arb | 2 + lib/l10n/arb/app_it.arb | 2 + lib/l10n/arb/app_localizations_ar.dart | 4 +- lib/l10n/arb/app_localizations_es.dart | 4 +- lib/l10n/arb/app_localizations_fr.dart | 4 +- lib/l10n/arb/app_localizations_he.dart | 4 +- lib/l10n/arb/app_localizations_hu.dart | 4 +- lib/l10n/arb/app_localizations_it.dart | 4 +- lib/l10n/arb/app_localizations_nl.dart | 4 +- lib/l10n/arb/app_localizations_pt.dart | 4 +- lib/l10n/arb/app_localizations_zh.dart | 5 +- lib/l10n/arb/app_nl.arb | 2 + lib/l10n/arb/app_pt.arb | 2 + lib/l10n/arb/app_zh.arb | 2 + ...ration_v218_site_detail_sections_test.dart | 5 +- ...quipment_set_computer_auto_apply_test.dart | 99 +++++++++++++++++++ .../pages/equipment_set_edit_page_test.dart | 17 +++- 21 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 test/core/database/migration_v219_equipment_set_computer_auto_apply_test.dart diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 39bb88d86f..3f7336266e 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -205,6 +205,8 @@ "equipment_components_title": "المكوّنات", "equipment_setEdit_defaultSwitch_title": "المجموعة الافتراضية", "equipment_setEdit_defaultSwitch_subtitle": "تُطبَّق تلقائيًا على الغطسات الجديدة التي لا تحتوي على معدات بعد", + "equipment_setEdit_computerAutoApplySwitch_title": "التطبيق عند استيراد كمبيوتر هذه المجموعة", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "إذا كانت هذه المجموعة تتضمن كمبيوتر غوص، تتم إضافة المجموعة كاملة تلقائيًا إلى الغطسة التي يتم تنزيلها أو استيرادها منه", "equipment_setEdit_geofencesTitle": "الأسوار الجغرافية", "equipment_setEdit_geofencesSubtitle": "اقترح هذه المجموعة تلقائيًا للغطسات القريبة من هذه المواقع", "equipment_setEdit_addGeofence": "إضافة سياج جغرافي", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index fa023467e2..6f08b46443 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -205,6 +205,8 @@ "equipment_components_title": "Componentes", "equipment_setEdit_defaultSwitch_title": "Conjunto predeterminado", "equipment_setEdit_defaultSwitch_subtitle": "Se aplica automáticamente a las inmersiones nuevas que aún no tienen equipo", + "equipment_setEdit_computerAutoApplySwitch_title": "Aplicar al importar el ordenador de este set", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "Si este set incluye un ordenador de buceo, añade automáticamente todo el set a una inmersión descargada o importada desde él", "equipment_setEdit_geofencesTitle": "Geocercas", "equipment_setEdit_geofencesSubtitle": "Sugerir automáticamente este conjunto para inmersiones cerca de estas ubicaciones", "equipment_setEdit_addGeofence": "Añadir geocerca", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index cf06445796..72afd03e17 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -205,6 +205,8 @@ "equipment_components_title": "Composants", "equipment_setEdit_defaultSwitch_title": "Ensemble par défaut", "equipment_setEdit_defaultSwitch_subtitle": "Appliqué automatiquement aux nouvelles plongées sans équipement", + "equipment_setEdit_computerAutoApplySwitch_title": "Appliquer lors de l'importation de l'ordinateur de ce set", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "Si ce set inclut un ordinateur de plongée, ajoute automatiquement tout le set à une plongée téléchargée ou importée depuis celui-ci", "equipment_setEdit_geofencesTitle": "Géorepères", "equipment_setEdit_geofencesSubtitle": "Suggérer automatiquement cet ensemble pour les plongées près de ces lieux", "equipment_setEdit_addGeofence": "Ajouter un géorepère", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 35c1857da6..bae556956e 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -205,6 +205,8 @@ "equipment_components_title": "רכיבים", "equipment_setEdit_defaultSwitch_title": "ערכת ברירת מחדל", "equipment_setEdit_defaultSwitch_subtitle": "מוחלת אוטומטית על צלילות חדשות שאין בהן ציוד עדיין", + "equipment_setEdit_computerAutoApplySwitch_title": "החל בעת ייבוא המחשב של סט זה", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "אם סט זה כולל מחשב צלילה, הסט כולו יתווסף אוטומטית לצלילה שהורדה או יובאה ממנו", "equipment_setEdit_geofencesTitle": "גדרות גאוגרפיות", "equipment_setEdit_geofencesSubtitle": "הצע ערכה זו אוטומטית לצלילות ליד מיקומים אלה", "equipment_setEdit_addGeofence": "הוסף גדר גאוגרפית", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index e2d822d3f2..9eba561bcb 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -205,6 +205,8 @@ "equipment_components_title": "Alkatrészek", "equipment_setEdit_defaultSwitch_title": "Alapértelmezett készlet", "equipment_setEdit_defaultSwitch_subtitle": "Automatikusan alkalmazza az új, még felszerelés nélküli merülésekre", + "equipment_setEdit_computerAutoApplySwitch_title": "Alkalmazás, ha ennek a készletnek a számítógépét importálják", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "Ha ez a készlet tartalmaz egy merülőkomputert, a teljes készlet automatikusan hozzáadódik egy erről letöltött vagy importált merüléshez", "equipment_setEdit_geofencesTitle": "Geokerítések", "equipment_setEdit_geofencesSubtitle": "Automatikusan javasolja ezt a készletet az e helyek közelében végzett merülésekhez", "equipment_setEdit_addGeofence": "Geokerítés hozzáadása", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 681cae5cf8..51d7dea438 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -205,6 +205,8 @@ "equipment_components_title": "Componenti", "equipment_setEdit_defaultSwitch_title": "Set predefinito", "equipment_setEdit_defaultSwitch_subtitle": "Applicato automaticamente alle nuove immersioni senza attrezzatura", + "equipment_setEdit_computerAutoApplySwitch_title": "Applica quando viene importato il computer di questo set", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "Se questo set include un computer da immersione, aggiunge automaticamente l'intero set a un'immersione scaricata o importata da esso", "equipment_setEdit_geofencesTitle": "Geofence", "equipment_setEdit_geofencesSubtitle": "Suggerisci automaticamente questo set per le immersioni vicino a queste posizioni", "equipment_setEdit_addGeofence": "Aggiungi geofence", diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 339a76b86b..fb8a542c7e 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -216,11 +216,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'التطبيق عند استيراد كمبيوتر هذه المجموعة'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'إذا كانت هذه المجموعة تتضمن كمبيوتر غوص، تتم إضافة المجموعة كاملة تلقائيًا إلى الغطسة التي يتم تنزيلها أو استيرادها منه'; @override String get equipment_setEdit_geofencesTitle => 'الأسوار الجغرافية'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index e636a09251..aed6e1d6fd 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -218,11 +218,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'Aplicar al importar el ordenador de este set'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'Si este set incluye un ordenador de buceo, añade automáticamente todo el set a una inmersión descargada o importada desde él'; @override String get equipment_setEdit_geofencesTitle => 'Geocercas'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 8f1fa6ddfe..ee0ee0627b 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -219,11 +219,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'Appliquer lors de l\'importation de l\'ordinateur de ce set'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'Si ce set inclut un ordinateur de plongée, ajoute automatiquement tout le set à une plongée téléchargée ou importée depuis celui-ci'; @override String get equipment_setEdit_geofencesTitle => 'Géorepères'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 3f0ca57ec7..dbe47d11f5 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -215,11 +215,11 @@ class AppLocalizationsHe extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'החל בעת ייבוא המחשב של סט זה'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'אם סט זה כולל מחשב צלילה, הסט כולו יתווסף אוטומטית לצלילה שהורדה או יובאה ממנו'; @override String get equipment_setEdit_geofencesTitle => 'גדרות גאוגרפיות'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 4b5cdd7502..dd79c4db6d 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -218,11 +218,11 @@ class AppLocalizationsHu extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'Alkalmazás, ha ennek a készletnek a számítógépét importálják'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'Ha ez a készlet tartalmaz egy merülőkomputert, a teljes készlet automatikusan hozzáadódik egy erről letöltött vagy importált merüléshez'; @override String get equipment_setEdit_geofencesTitle => 'Geokerítések'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 4c0710d24f..965a0d2e98 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -219,11 +219,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'Applica quando viene importato il computer di questo set'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'Se questo set include un computer da immersione, aggiunge automaticamente l\'intero set a un\'immersione scaricata o importata da esso'; @override String get equipment_setEdit_geofencesTitle => 'Geofence'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 402f88d8c3..e01c8d703b 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -216,11 +216,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'Toepassen wanneer de computer van deze set wordt geïmporteerd'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'Als deze set een duikcomputer bevat, wordt de hele set automatisch toegevoegd aan een duik die van deze computer is gedownload of geïmporteerd'; @override String get equipment_setEdit_geofencesTitle => 'Geofences'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index eeb80006f9..ed3673bab8 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -218,11 +218,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + 'Aplicar quando o computador deste conjunto for importado'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + 'Se este conjunto incluir um computador de mergulho, adiciona automaticamente todo o conjunto a uma imersão descarregada ou importada dele'; @override String get equipment_setEdit_geofencesTitle => 'Geocercas'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index b534fb0e3b..33437d246a 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -205,12 +205,11 @@ class AppLocalizationsZh extends AppLocalizations { String get equipment_setEdit_defaultSwitch_subtitle => '自动应用于尚无装备的新潜水'; @override - String get equipment_setEdit_computerAutoApplySwitch_title => - 'Apply when this set\'s computer is imported'; + String get equipment_setEdit_computerAutoApplySwitch_title => '导入该套装的电脑时自动应用'; @override String get equipment_setEdit_computerAutoApplySwitch_subtitle => - 'If this set includes a dive computer, auto-add the whole set to a dive downloaded or imported from it'; + '如果该套装包含潜水电脑,则从该电脑下载或导入的潜水记录会自动添加整个套装'; @override String get equipment_setEdit_geofencesTitle => '地理围栏'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index a6ad9f3eb5..3da88947d1 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -205,6 +205,8 @@ "equipment_components_title": "Onderdelen", "equipment_setEdit_defaultSwitch_title": "Standaardset", "equipment_setEdit_defaultSwitch_subtitle": "Automatisch toegepast op nieuwe duiken zonder uitrusting", + "equipment_setEdit_computerAutoApplySwitch_title": "Toepassen wanneer de computer van deze set wordt geïmporteerd", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "Als deze set een duikcomputer bevat, wordt de hele set automatisch toegevoegd aan een duik die van deze computer is gedownload of geïmporteerd", "equipment_setEdit_geofencesTitle": "Geofences", "equipment_setEdit_geofencesSubtitle": "Deze set automatisch voorstellen voor duiken bij deze locaties", "equipment_setEdit_addGeofence": "Geofence toevoegen", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 868d546dca..8b088da105 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -205,6 +205,8 @@ "equipment_components_title": "Componentes", "equipment_setEdit_defaultSwitch_title": "Conjunto padrão", "equipment_setEdit_defaultSwitch_subtitle": "Aplicado automaticamente a novos mergulhos que ainda não têm equipamento", + "equipment_setEdit_computerAutoApplySwitch_title": "Aplicar quando o computador deste conjunto for importado", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "Se este conjunto incluir um computador de mergulho, adiciona automaticamente todo o conjunto a uma imersão descarregada ou importada dele", "equipment_setEdit_geofencesTitle": "Geocercas", "equipment_setEdit_geofencesSubtitle": "Sugerir automaticamente este conjunto para mergulhos perto destes locais", "equipment_setEdit_addGeofence": "Adicionar geocerca", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index ad215ac704..ee8d2d262b 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -205,6 +205,8 @@ "equipment_components_title": "组件", "equipment_setEdit_defaultSwitch_title": "默认套装", "equipment_setEdit_defaultSwitch_subtitle": "自动应用于尚无装备的新潜水", + "equipment_setEdit_computerAutoApplySwitch_title": "导入该套装的电脑时自动应用", + "equipment_setEdit_computerAutoApplySwitch_subtitle": "如果该套装包含潜水电脑,则从该电脑下载或导入的潜水记录会自动添加整个套装", "equipment_setEdit_geofencesTitle": "地理围栏", "equipment_setEdit_geofencesSubtitle": "自动为这些位置附近的潜水推荐此套装", "equipment_setEdit_addGeofence": "添加地理围栏", diff --git a/test/core/database/migration_v218_site_detail_sections_test.dart b/test/core/database/migration_v218_site_detail_sections_test.dart index 31344a478d..56a2ed85c5 100644 --- a/test/core/database/migration_v218_site_detail_sections_test.dart +++ b/test/core/database/migration_v218_site_detail_sections_test.dart @@ -56,7 +56,7 @@ void main() { }, ); - test('a v217 database upgrades to v218 with both columns', () async { + test('a v217 database upgrades and gains both columns', () async { final nativeDb = NativeDatabase.memory( setup: (rawDb) { rawDb.execute('PRAGMA user_version = 217'); @@ -77,6 +77,9 @@ void main() { .get(); final names = cols.map((c) => c.read('name')).toSet(); expect(names, containsAll(_columns)); + // Not asserted against 218: onUpgrade runs the whole span in one call, so + // a v217 database upgrading today lands on the current version, not on + // the version this rung happened to introduce. final version = await db.customSelect('PRAGMA user_version').getSingle(); expect(version.read('user_version'), AppDatabase.currentSchemaVersion); }); diff --git a/test/core/database/migration_v219_equipment_set_computer_auto_apply_test.dart b/test/core/database/migration_v219_equipment_set_computer_auto_apply_test.dart new file mode 100644 index 0000000000..166cbad518 --- /dev/null +++ b/test/core/database/migration_v219_equipment_set_computer_auto_apply_test.dart @@ -0,0 +1,99 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +void main() { + test('v219 is the current schema version and is in the ladder', () { + // The newest rung owns the exact assertion; relax it to + // greaterThanOrEqualTo when the next one lands. + expect(AppDatabase.currentSchemaVersion, 219); + expect(AppDatabase.migrationVersions, contains(219)); + }); + + test('the column is additive, so the sync floor does not move', () { + expect(AppDatabase.minimumCompatibleSchemaVersion, 210); + }); + + test('a fresh database has the column, defaulting to off', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('equipment_sets')") + .get(); + final byName = {for (final c in cols) c.read('name'): c}; + expect(byName, contains('auto_apply_on_computer_import')); + expect(byName['auto_apply_on_computer_import']!.read('notnull'), 1); + }); + + test( + 'a database stranded before v219 gains the column via beforeOpen', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute(''' + CREATE TABLE equipment_sets ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_at INTEGER, + updated_at INTEGER + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('equipment_sets')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('auto_apply_on_computer_import')); + }, + ); + + test( + 'a v218 database upgrades and gains the column, defaulting to off', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 218'); + rawDb.execute(''' + CREATE TABLE equipment_sets ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_at INTEGER, + updated_at INTEGER + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('equipment_sets')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('auto_apply_on_computer_import')); + final version = await db.customSelect('PRAGMA user_version').getSingle(); + expect( + version.read('user_version'), + AppDatabase.currentSchemaVersion, + ); + }, + ); + + test('the assert is a no-op when the table is absent', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('CREATE TABLE unrelated (id TEXT)'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + await db.customSelect('SELECT 1').get(); + }); +} diff --git a/test/features/equipment/presentation/pages/equipment_set_edit_page_test.dart b/test/features/equipment/presentation/pages/equipment_set_edit_page_test.dart index e181163780..82fb2bb4a8 100644 --- a/test/features/equipment/presentation/pages/equipment_set_edit_page_test.dart +++ b/test/features/equipment/presentation/pages/equipment_set_edit_page_test.dart @@ -100,11 +100,12 @@ void main() { // Name the set. await tester.enterText(find.byType(TextFormField).first, 'Cold Water'); - // Toggle the Default switch on. - await tester.tap(find.byType(SwitchListTile)); + // Toggle the Default switch on (the first of the two set-level + // switches; the second is the computer-auto-apply opt-in, issue #1020). + await tester.tap(find.byType(SwitchListTile).first); await tester.pump(); expect( - tester.widget(find.byType(SwitchListTile)).value, + tester.widget(find.byType(SwitchListTile).first).value, isTrue, ); @@ -133,7 +134,7 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byType(TextFormField).first, 'Cold Water'); - await tester.tap(find.byType(SwitchListTile)); + await tester.tap(find.byType(SwitchListTile).first); await tester.pump(); await addGeofenceViaSheet(tester); @@ -188,6 +189,14 @@ void main() { // Open the editor: the set provider caches all three members. await tester.pumpWidget(await buildPage(setId: 's1', realEquipment: true)); await tester.pumpAndSettle(); + // The equipment list sits below the fold of a lazily-built ListView + // (more so now, with the computer-auto-apply switch added, issue #1020), + // so scroll it into view before asserting on it. + await tester.scrollUntilVisible( + find.text('e2'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('e2'), findsOneWidget); // The diver deletes the BCD from the equipment tab -- through the same From db56581e35f1364363edf7f1075e2829d79141b3 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:51:57 +0200 Subject: [PATCH 4/8] Renumber the equipment-set-computer migration test to v220 Rebasing onto upstream/main after #1964 landed schema v219 (equipment tags) required renumbering this branch's rung from 219 to 220; this renames the migration test file to match and updates its version literals accordingly. --- ...v220_equipment_set_computer_auto_apply_test.dart} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename test/core/database/{migration_v219_equipment_set_computer_auto_apply_test.dart => migration_v220_equipment_set_computer_auto_apply_test.dart} (88%) diff --git a/test/core/database/migration_v219_equipment_set_computer_auto_apply_test.dart b/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart similarity index 88% rename from test/core/database/migration_v219_equipment_set_computer_auto_apply_test.dart rename to test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart index 166cbad518..3e873ec565 100644 --- a/test/core/database/migration_v219_equipment_set_computer_auto_apply_test.dart +++ b/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart @@ -4,11 +4,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/database/database.dart'; void main() { - test('v219 is the current schema version and is in the ladder', () { + test('v220 is the current schema version and is in the ladder', () { // The newest rung owns the exact assertion; relax it to // greaterThanOrEqualTo when the next one lands. - expect(AppDatabase.currentSchemaVersion, 219); - expect(AppDatabase.migrationVersions, contains(219)); + expect(AppDatabase.currentSchemaVersion, 220); + expect(AppDatabase.migrationVersions, contains(220)); }); test('the column is additive, so the sync floor does not move', () { @@ -28,7 +28,7 @@ void main() { }); test( - 'a database stranded before v219 gains the column via beforeOpen', + 'a database stranded before v220 gains the column via beforeOpen', () async { final nativeDb = NativeDatabase.memory( setup: (rawDb) { @@ -54,11 +54,11 @@ void main() { ); test( - 'a v218 database upgrades and gains the column, defaulting to off', + 'a v219 database upgrades and gains the column, defaulting to off', () async { final nativeDb = NativeDatabase.memory( setup: (rawDb) { - rawDb.execute('PRAGMA user_version = 218'); + rawDb.execute('PRAGMA user_version = 219'); rawDb.execute(''' CREATE TABLE equipment_sets ( id TEXT NOT NULL PRIMARY KEY, From d9c4c1e359a683ee0068b140b5325cb5b21f3bd1 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:47:16 +0200 Subject: [PATCH 5/8] Relax v219 equipment-tags migration test now that v220 exists Mirrors the same relaxation already applied to the v218 test: the newest rung owns the exact currentSchemaVersion/step-count assertions, so v219's own test (from #1964) needs the same greaterThanOrEqualTo treatment now that this branch's v220 sits on top of it. --- .../database/migration_v219_equipment_tags_test.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/core/database/migration_v219_equipment_tags_test.dart b/test/core/database/migration_v219_equipment_tags_test.dart index e66f8697ca..b804be65e6 100644 --- a/test/core/database/migration_v219_equipment_tags_test.dart +++ b/test/core/database/migration_v219_equipment_tags_test.dart @@ -86,12 +86,12 @@ void main() { return rows.isEmpty ? null : rows.single.read('sql'); } - test('v219 is the current schema version and is in the ladder', () { - // The newest rung owns the exact assertion; relax it to - // greaterThanOrEqualTo when the next one lands. - expect(AppDatabase.currentSchemaVersion, 219); + test('v219 is in the ladder', () { + // The newest rung owns the exact currentSchemaVersion/step-count + // assertions; relaxed here once v220 (issue #1020) landed on top. + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(219)); expect(AppDatabase.migrationVersions, contains(219)); - expect(AppDatabase.migrationStepCount(218), 1); + expect(AppDatabase.migrationStepCount(218), greaterThanOrEqualTo(1)); // Additive rung: the sync compatibility floor must not move. expect(AppDatabase.minimumCompatibleSchemaVersion, 210); }); From c35bc880827502054d3e8379a5169dfa80a50a17 Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:05:56 +0200 Subject: [PATCH 6/8] Scope computer-set matching to the dive's own diver, combine queries Code review of #1020 surfaced a real cross-diver leak: the set lookup had no diverId filter, so in a shared local database (e.g. dive buddies syncing one install) a set another diver built around a shared computer could silently attach that diver's unrelated gear to this diver's dive. Scopes the lookup to the dive's own diverId, mirroring DiveEquipmentDefaulter's existing null-diverId handling (an owner-less dive is skipped rather than crossing diver scopes). Also folds the two sequential queries (set membership, then opt-in filter) into one joined customSelect, adds the missing exact migrationStepCount assertion for the v220 rung, and adds the explanatory comment to the second (replace-source) call site that the first one already had. --- .../dive_computer_repository_impl.dart | 2 + .../equipment_set_for_computer_linker.dart | 44 ++++++++++---- ...quipment_set_computer_auto_apply_test.dart | 1 + ...quipment_set_for_computer_linker_test.dart | 59 ++++++++++++++++++- 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart index efcdd00fe6..d81cd3d4bb 100644 --- a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart @@ -1769,6 +1769,8 @@ 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, ); diff --git a/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart index cace6035d3..ee92a39286 100644 --- a/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart +++ b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart @@ -47,6 +47,13 @@ class EquipmentSetForComputerLinker { 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 linkComputerSetsForDive({required String diveId}) async { if (DatabaseService.instance.databaseOrNull == null) return false; try { @@ -54,20 +61,31 @@ class EquipmentSetForComputerLinker { .gearTwinEquipmentIdsForDive(diveId); if (computerEquipmentIds.isEmpty) return false; - final memberRows = await (_db.select( - _db.equipmentSetItems, - )..where((t) => t.equipmentId.isIn(computerEquipmentIds))).get(); - final candidateSetIds = memberRows.map((r) => r.setId).toSet(); - if (candidateSetIds.isEmpty) return false; + final dive = await (_db.select( + _db.dives, + )..where((t) => t.id.equals(diveId))).getSingleOrNull(); + final diverId = dive?.diverId; + if (diverId == null) return false; - final optedInRows = - await (_db.select(_db.equipmentSets)..where( - (t) => - t.id.isIn(candidateSetIds) & - t.autoApplyOnComputerImport.equals(true), - )) - .get(); - final setIds = optedInRows.map((r) => r.id).toSet(); + // 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(id), + Variable(diverId), + ], + readsFrom: {_db.equipmentSets, _db.equipmentSetItems}, + ) + .get(); + final setIds = rows.map((row) => row.read('id')).toSet(); if (setIds.isEmpty) return false; var appliedAny = false; diff --git a/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart b/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart index 3e873ec565..59de831902 100644 --- a/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart +++ b/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart @@ -9,6 +9,7 @@ void main() { // greaterThanOrEqualTo when the next one lands. expect(AppDatabase.currentSchemaVersion, 220); expect(AppDatabase.migrationVersions, contains(220)); + expect(AppDatabase.migrationStepCount(219), 1); }); test('the column is additive, so the sync floor does not move', () { diff --git a/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart index 37801facb6..32609008df 100644 --- a/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart +++ b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart @@ -55,9 +55,24 @@ void main() { ); } + Future insertDive(String id, {String? diverId = 'd1'}) async { + final t = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.dives) + .insert( + DivesCompanion.insert( + id: id, + diverId: Value(diverId), + diveDateTime: t, + createdAt: t, + updatedAt: t, + ), + ); + } + Future insertSet( String id, { - String? diverId, + String? diverId = 'd1', bool autoApplyOnComputerImport = true, }) async { final t = DateTime.now().millisecondsSinceEpoch; @@ -102,6 +117,7 @@ void main() { await addToSet('set-ccr', 'gear-computer'); await addToSet('set-ccr', 'gear-drysuit'); await addToSet('set-ccr', 'gear-fins'); + await insertDive('dive1'); await linkSource('dive1', 'c1'); expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); @@ -123,6 +139,7 @@ void main() { await insertSet('set-bailout'); await addToSet('set-bailout', 'gear-computer'); await addToSet('set-bailout', 'gear-bailout'); + await insertDive('dive1'); await linkSource('dive1', 'c1'); expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); @@ -140,6 +157,7 @@ void main() { await insertSet('set-ccr'); await addToSet('set-ccr', 'gear-computer'); await addToSet('set-ccr', 'gear-drysuit'); + await insertDive('dive1'); await linkSource('dive1', 'c1'); await db .into(db.diveEquipment) @@ -169,6 +187,7 @@ void main() { await insertSet('set-ccr'); await addToSet('set-ccr', 'gear-computer'); await addToSet('set-ccr', 'gear-fins'); + await insertDive('dive1'); await linkSource('dive1', 'c1'); await db .into(db.diveEquipment) @@ -197,6 +216,7 @@ void main() { await insertSet('set-ccr', autoApplyOnComputerImport: false); await addToSet('set-ccr', 'gear-computer'); await addToSet('set-ccr', 'gear-drysuit'); + await insertDive('dive1'); await linkSource('dive1', 'c1'); expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); @@ -217,6 +237,7 @@ void main() { await insertSet('set-other', autoApplyOnComputerImport: false); await addToSet('set-other', 'gear-computer'); await addToSet('set-other', 'gear-bailout'); + await insertDive('dive1'); await linkSource('dive1', 'c1'); expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isTrue); @@ -227,6 +248,41 @@ void main() { test('is a no-op when the computer belongs to no set', () async { await insertGear('gear-computer'); await insertComputer('c1', equipmentId: 'gear-computer'); + await insertDive('dive1'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + expect(await equipmentOn('dive1'), isEmpty); + }); + + test( + 'is a no-op when the matching set belongs to a different diver', + () async { + // A dive computer can be shared across diver profiles in one local + // database; a set another diver built around it must not silently + // attach that diver's gear to this diver's dive. + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-other-diver', diverId: 'd2'); + await addToSet('set-other-diver', 'gear-computer'); + await addToSet('set-other-diver', 'gear-drysuit'); + await insertDive('dive1', diverId: 'd1'); + await linkSource('dive1', 'c1'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + expect(await equipmentOn('dive1'), isEmpty); + }, + ); + + test('is a no-op for an owner-less dive', () async { + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-ccr'); + await addToSet('set-ccr', 'gear-computer'); + await addToSet('set-ccr', 'gear-drysuit'); + await insertDive('dive1', diverId: null); await linkSource('dive1', 'c1'); expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); @@ -253,6 +309,7 @@ void main() { await insertSet('set-ccr'); await addToSet('set-ccr', 'gear-computer'); await addToSet('set-ccr', 'gear-drysuit'); + await insertDive('dive1'); await linkSource('dive1', 'c1'); await linker.linkComputerSetsForDive(diveId: 'dive1'); From feaffbb2515200cca853bfcbb752f10639424dad Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:45:44 +0200 Subject: [PATCH 7/8] Report and sync a partial success in the computer-set linker Copilot review: appliedAny was scoped inside the try block, so an exception thrown after one or more sets had already been applied (e.g. a later set's getEquipmentIdsInSet throwing) would skip SyncEventBus.notifyLocalChange() and return false even though the database had already been modified -- masking a real write from sync and the caller. Moves appliedAny outside the try so the catch and a new finally can see it: the finally always notifies when anything applied, and the catch returns the partial result instead of a hardcoded false. --- .../equipment_set_for_computer_linker.dart | 18 +++++-- ...quipment_set_for_computer_linker_test.dart | 54 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart index ee92a39286..04a5ef4ac0 100644 --- a/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart +++ b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart @@ -56,6 +56,10 @@ class EquipmentSetForComputerLinker { /// less) is skipped entirely rather than crossing diver scopes. Future 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); @@ -88,7 +92,6 @@ class EquipmentSetForComputerLinker { final setIds = rows.map((row) => row.read('id')).toSet(); if (setIds.isEmpty) return false; - var appliedAny = false; for (final setId in setIds) { final setEquipmentIds = await _sets.getEquipmentIdsInSet(setId); if (setEquipmentIds.isEmpty) continue; @@ -99,11 +102,18 @@ class EquipmentSetForComputerLinker { ); appliedAny = true; } - if (appliedAny) SyncEventBus.notifyLocalChange(); return appliedAny; } catch (_) { - // Best-effort: never let this fail the dive operation. - return false; + // 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(); } } } diff --git a/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart index 32609008df..244c11a259 100644 --- a/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart +++ b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart @@ -2,10 +2,25 @@ import 'package:drift/drift.dart' hide isNull; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; import 'package:submersion/features/equipment/data/services/equipment_set_for_computer_linker.dart'; import '../../../../helpers/test_database.dart'; +/// Throws on every call after the first, to simulate one set applying +/// successfully before a later one fails mid-loop. +class _FailAfterFirstSetRepository extends EquipmentSetRepository { + var _calls = 0; + + @override + Future> getEquipmentIdsInSet(String setId) async { + _calls++; + if (_calls > 1) throw Exception('boom'); + return super.getEquipmentIdsInSet(setId); + } +} + void main() { late AppDatabase db; late EquipmentSetForComputerLinker linker; @@ -318,6 +333,45 @@ void main() { expect(await equipmentOn('dive1'), {'gear-computer', 'gear-drysuit'}); }); + test( + 'reports and notifies a partial success when a later set fails mid-loop', + () async { + // Two opted-in sets; the fake throws once the first has already been + // applied, simulating a failure partway through the loop. The already- + // written set must still be reported and synced, not masked as false. + await insertGear('gear-computer'); + await insertGear('gear-drysuit', type: 'exposure'); + await insertGear('gear-bailout', type: 'cylinder'); + await insertComputer('c1', equipmentId: 'gear-computer'); + await insertSet('set-a'); + await addToSet('set-a', 'gear-computer'); + await addToSet('set-a', 'gear-drysuit'); + await insertSet('set-b'); + await addToSet('set-b', 'gear-computer'); + await addToSet('set-b', 'gear-bailout'); + await insertDive('dive1'); + await linkSource('dive1', 'c1'); + + final failingLinker = EquipmentSetForComputerLinker( + equipmentSetRepository: _FailAfterFirstSetRepository(), + ); + final notified = SyncEventBus.changes.first; + + expect( + await failingLinker.linkComputerSetsForDive(diveId: 'dive1'), + isTrue, + ); + final applied = await equipmentOn('dive1'); + expect(applied, contains('gear-computer')); + expect( + applied.contains('gear-drysuit') ^ applied.contains('gear-bailout'), + isTrue, + reason: 'exactly one set should have written before the failure', + ); + await expectLater(notified, completes); + }, + ); + test('returns false instead of throwing when the read fails', () async { await insertGear('gear-computer'); await insertComputer('c1', equipmentId: 'gear-computer'); From 4c29a9a56bff72a50e49a2b2e41bffa35529265c Mon Sep 17 00:00:00 2001 From: alpheios-one <275321969+alpheios-one@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:24:40 +0200 Subject: [PATCH 8/8] Trigger CI rerun (Build Android hit a transient Maven Central 429)