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/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..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 @@ -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,11 @@ class DiveComputerRepository { // existing dive, but the computer did log it. Idempotent through // insertOnConflictUpdate. await DiveComputerGearLinker().linkComputerGearForDive(diveId: diveId); + // Apply every equipment set that lists this computer as a member + // (issue #1020). Additive, independent of the defaulter above. + await EquipmentSetForComputerLinker().linkComputerSetsForDive( + diveId: diveId, + ); } // Note: Computer stats (incrementDiveCount, updateLastDownload) are 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/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..04a5ef4ac0 --- /dev/null +++ b/lib/features/equipment/data/services/equipment_set_for_computer_linker.dart @@ -0,0 +1,119 @@ +import 'package:drift/drift.dart'; + +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; +import 'package:submersion/features/equipment/data/services/dive_computer_gear_linker.dart'; + +/// Applies every equipment set that lists a dive's computer as a member and +/// has opted in to `autoApplyOnComputerImport` (issue #1020). +/// +/// A diver who keeps a computer permanently paired with the rest of a rig +/// (e.g. a CCR controller with drysuit, tec fins, bailout) models that by +/// adding the computer's gear twin to an `EquipmentSet` alongside the other +/// items, then turning the set's "apply when this computer is imported" +/// switch on -- off by default, so existing sets are unaffected until a +/// diver opts in. Once the computer that logged this dive is known, this +/// service looks up every opted-in set containing that computer's gear-twin +/// equipment id and adds that set's full roster. +/// +/// Runs at every seam `DiveComputerGearLinker` runs at, after it. Unlike +/// `DiveEquipmentDefaulter` this is NOT gated on the dive being empty and NOT +/// mutually exclusive with a geofenced/default set: a diver can have both a +/// location-based set and a computer-based set match the same dive, in which +/// case both are applied (the issue itself accepts this as a case the diver +/// may need to correct manually). Several sets can list the same computer; +/// all of them are applied. `bulkAddEquipment` is idempotent per equipment +/// id and only fills a still-null `viaSetId`, so an overlapping or repeated +/// application never duplicates a row or steals another set's provenance. +/// +/// Best-effort: any failure is swallowed so this can never abort a download +/// or import that has already persisted the dive. +class EquipmentSetForComputerLinker { + EquipmentSetForComputerLinker({ + DiveComputerGearLinker? gearLinker, + EquipmentSetRepository? equipmentSetRepository, + DiveRepository? diveRepository, + }) : _gearLinker = gearLinker ?? DiveComputerGearLinker(), + _sets = equipmentSetRepository ?? EquipmentSetRepository(), + _dives = diveRepository ?? DiveRepository(); + + final DiveComputerGearLinker _gearLinker; + final EquipmentSetRepository _sets; + final DiveRepository _dives; + + AppDatabase get _db => DatabaseService.instance.database; + + /// Returns true when at least one set was applied. + /// + /// Scoped to the dive's own diver, same as `DiveEquipmentDefaulter`: a + /// dive computer can be shared across diver profiles in one local + /// database (e.g. buddies syncing one install), and without this a set + /// another diver built around that same computer would silently attach + /// its unrelated gear to this diver's dive. A dive with no diver (owner- + /// less) is skipped entirely rather than crossing diver scopes. + Future linkComputerSetsForDive({required String diveId}) async { + if (DatabaseService.instance.databaseOrNull == null) return false; + // Declared outside the try so a failure partway through the loop below + // still reports (and notifies sync about) whichever sets already wrote + // successfully, instead of masking a real DB change as "nothing happened". + var appliedAny = false; + try { + final computerEquipmentIds = await _gearLinker + .gearTwinEquipmentIdsForDive(diveId); + if (computerEquipmentIds.isEmpty) return false; + + final dive = await (_db.select( + _db.dives, + )..where((t) => t.id.equals(diveId))).getSingleOrNull(); + final diverId = dive?.diverId; + if (diverId == null) return false; + + // One joined query for "opted-in sets, owned by this diver, that + // contain one of these computers" rather than a set-membership + // lookup followed by a separate opt-in filter. + final placeholders = computerEquipmentIds.map((_) => '?').join(','); + final rows = await _db + .customSelect( + 'SELECT DISTINCT s.id FROM equipment_sets s ' + 'JOIN equipment_set_items i ON i.set_id = s.id ' + 'WHERE i.equipment_id IN ($placeholders) ' + 'AND s.auto_apply_on_computer_import = 1 ' + 'AND s.diver_id = ?', + variables: [ + for (final id in computerEquipmentIds) Variable(id), + Variable(diverId), + ], + readsFrom: {_db.equipmentSets, _db.equipmentSetItems}, + ) + .get(); + final setIds = rows.map((row) => row.read('id')).toSet(); + if (setIds.isEmpty) return false; + + for (final setId in setIds) { + final setEquipmentIds = await _sets.getEquipmentIdsInSet(setId); + if (setEquipmentIds.isEmpty) continue; + await _dives.bulkAddEquipment( + [diveId], + setEquipmentIds, + viaSetId: setId, + ); + appliedAny = true; + } + return appliedAny; + } catch (_) { + // Best-effort: never let this fail the dive operation. appliedAny may + // already be true if an earlier set in the loop wrote successfully + // before a later one threw; report that partial success rather than + // masking it as false. + return appliedAny; + } finally { + // Runs once regardless of which path returned, so a partial success + // followed by a thrown exception still notifies sync about the sets + // that did get written. + if (appliedAny) SyncEventBus.notifyLocalChange(); + } + } +} 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/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/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_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_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.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..fb8a542c7e 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 => + 'التطبيق عند استيراد كمبيوتر هذه المجموعة'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'إذا كانت هذه المجموعة تتضمن كمبيوتر غوص، تتم إضافة المجموعة كاملة تلقائيًا إلى الغطسة التي يتم تنزيلها أو استيرادها منه'; + @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..aed6e1d6fd 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 => + 'Aplicar al importar el ordenador de este set'; + + @override + String get 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'; + @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..ee0ee0627b 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 => + 'Appliquer lors de l\'importation de l\'ordinateur de ce set'; + + @override + String get 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'; + @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..dbe47d11f5 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 => + 'החל בעת ייבוא המחשב של סט זה'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + 'אם סט זה כולל מחשב צלילה, הסט כולו יתווסף אוטומטית לצלילה שהורדה או יובאה ממנו'; + @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..dd79c4db6d 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 => + 'Alkalmazás, ha ennek a készletnek a számítógépét importálják'; + + @override + String get 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'; + @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..965a0d2e98 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 => + 'Applica quando viene importato il computer di questo set'; + + @override + String get 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'; + @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..e01c8d703b 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 => + 'Toepassen wanneer de computer van deze set wordt geïmporteerd'; + + @override + String get 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'; + @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..ed3673bab8 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 => + 'Aplicar quando o computador deste conjunto for importado'; + + @override + String get 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'; + @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..33437d246a 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -204,6 +204,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get equipment_setEdit_defaultSwitch_subtitle => '自动应用于尚无装备的新潜水'; + @override + String get equipment_setEdit_computerAutoApplySwitch_title => '导入该套装的电脑时自动应用'; + + @override + String get equipment_setEdit_computerAutoApplySwitch_subtitle => + '如果该套装包含潜水电脑,则从该电脑下载或导入的潜水记录会自动添加整个套装'; + @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_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); }); 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 new file mode 100644 index 0000000000..59de831902 --- /dev/null +++ b/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart @@ -0,0 +1,100 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +void main() { + 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, 220); + expect(AppDatabase.migrationVersions, contains(220)); + expect(AppDatabase.migrationStepCount(219), 1); + }); + + 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 v220 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 v219 database upgrades and gains the column, defaulting to off', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 219'); + 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/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..244c11a259 --- /dev/null +++ b/test/features/equipment/data/services/equipment_set_for_computer_linker_test.dart @@ -0,0 +1,383 @@ +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; + + 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 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 = 'd1', + bool autoApplyOnComputerImport = true, + }) async { + final t = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.equipmentSets) + .insert( + EquipmentSetsCompanion.insert( + id: id, + diverId: Value(diverId), + name: id, + autoApplyOnComputerImport: Value(autoApplyOnComputerImport), + 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 insertDive('dive1'); + 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 insertDive('dive1'); + 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 insertDive('dive1'); + 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 insertDive('dive1'); + 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 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 insertDive('dive1'); + 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 insertDive('dive1'); + 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'); + 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); + 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 insertDive('dive1'); + await linkSource('dive1', 'c1'); + + await linker.linkComputerSetsForDive(diveId: 'dive1'); + await linker.linkComputerSetsForDive(diveId: 'dive1'); + + 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'); + await linkSource('dive1', 'c1'); + await db.customStatement('DROP TABLE dive_data_sources'); + + expect(await linker.linkComputerSetsForDive(diveId: 'dive1'), isFalse); + }); +} 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