From be5910aed621ecbf00c017f5cc14ac828764ddb7 Mon Sep 17 00:00:00 2001 From: Wouter D'Haeseleer Date: Fri, 12 Jun 2026 08:44:03 +0200 Subject: [PATCH] When a imported site has detected a duplicate give the option to overwrite the current ones --- .../data/services/uddf_entity_importer.dart | 98 ++++++++++++++++++- .../data/adapters/universal_adapter.dart | 23 +++++ .../widgets/entity_review_list.dart | 36 ++++--- 3 files changed, 140 insertions(+), 17 deletions(-) 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 da94e7bdb..9f43839be 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -79,6 +79,11 @@ class UddfImportSelections { final Set tags; final Set diveTypes; final Set sites; + + /// Maps import-list index → existing site ID for sites the user chose to + /// overwrite. These indices are NOT included in [sites] (which creates new + /// entries); instead, the matching existing site is updated in place. + final Map siteOverrides; final Set equipmentSets; final Set dives; final Set courses; @@ -92,6 +97,7 @@ class UddfImportSelections { this.tags = const {}, this.diveTypes = const {}, this.sites = const {}, + this.siteOverrides = const {}, this.equipmentSets = const {}, this.dives = const {}, this.courses = const {}, @@ -313,6 +319,7 @@ class UddfEntityImporter { final sitesCount = await _importSites( data.sites, selections.sites, + selections.siteOverrides, repositories.siteRepository, diverId, siteIdMapping, @@ -730,6 +737,7 @@ class UddfEntityImporter { Future _importSites( List> items, Set selected, + Map overrides, SiteRepository repository, String diverId, Map idMapping, @@ -740,11 +748,14 @@ class UddfEntityImporter { // dives referencing them still get linked correctly. final existingSites = await repository.getAllSites(diverId: diverId); final existingByName = {}; + final existingById = {}; for (final site in existingSites) { existingByName[site.name.toLowerCase()] = site; + existingById[site.id] = site; } for (var i = 0; i < items.length; i++) { if (selected.contains(i)) continue; // will be imported below + if (overrides.containsKey(i)) continue; // will be overwritten below final uddfId = items[i]['uddfId'] as String?; final name = items[i]['name'] as String?; if (uddfId != null && name != null) { @@ -755,10 +766,91 @@ class UddfEntityImporter { } } - if (selected.isEmpty) return 0; - onProgress?.call(ImportPhase.sites, 0, selected.length); + final totalWork = selected.length + overrides.length; + if (totalWork == 0) return 0; + onProgress?.call(ImportPhase.sites, 0, totalWork); var count = 0; + // Handle overwrite (replaceSource): update existing sites in place. + for (final entry in overrides.entries) { + final i = entry.key; + final existingId = entry.value; + if (i >= items.length) continue; + final siteData = items[i]; + final name = siteData['name'] as String?; + if (name == null || name.isEmpty) continue; + + final existing = existingById[existingId]; + if (existing == null) continue; + + final uddfId = siteData['uddfId'] as String?; + final lat = siteData['latitude'] as double?; + final lon = siteData['longitude'] as double?; + + String? country = siteData['country'] as String?; + String? region = siteData['region'] as String?; + + if (lat != null && lon != null && (country == null || region == null)) { + try { + final geocodeResult = await LocationService.instance.reverseGeocode( + lat, + lon, + ); + country ??= geocodeResult.country; + region ??= geocodeResult.region; + } catch (_) { + // Geocoding is best-effort + } + } + + final difficultyStr = siteData['difficulty'] as String?; + final difficulty = difficultyStr != null + ? SiteDifficulty.fromString(difficultyStr) + : null; + + final overwrittenSite = DiveSite( + id: existingId, + diverId: diverId, + name: name, + description: siteData['description'] as String? ?? '', + location: (lat != null && lon != null) ? GeoPoint(lat, lon) : null, + minDepth: siteData['minDepth'] as double?, + maxDepth: siteData['maxDepth'] as double?, + difficulty: difficulty, + country: country, + region: region, + rating: siteData['rating'] as double?, + notes: siteData['notes'] as String? ?? '', + hazards: siteData['hazards'] as String?, + accessNotes: siteData['accessNotes'] as String?, + mooringNumber: siteData['mooringNumber'] as String?, + parkingInfo: siteData['parkingInfo'] as String?, + altitude: siteData['altitude'] as double?, + ); + + await repository.updateSite(overwrittenSite); + + final waterType = siteData['waterType'] as String?; + final bodyOfWater = siteData['bodyOfWater'] as String?; + if (waterType != null || bodyOfWater != null) { + await repository.applyImportedMetadata( + existingId, + DiveSitesCompanion( + waterType: waterType != null + ? Value(waterType) + : const Value.absent(), + bodyOfWater: bodyOfWater != null + ? Value(bodyOfWater) + : const Value.absent(), + ), + ); + } + + if (uddfId != null) idMapping[uddfId] = overwrittenSite; + count++; + onProgress?.call(ImportPhase.sites, count, totalWork); + } + for (var i = 0; i < items.length; i++) { if (!selected.contains(i)) continue; final siteData = items[i]; @@ -834,7 +926,7 @@ class UddfEntityImporter { if (uddfId != null) idMapping[uddfId] = createdSite; count++; - onProgress?.call(ImportPhase.sites, count, selected.length); + onProgress?.call(ImportPhase.sites, count, totalWork); } return count; diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index bc2183054..935ce46b0 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -140,6 +140,7 @@ class UniversalAdapter implements ImportSourceAdapter { Set get supportedDuplicateActions => const { DuplicateAction.skip, DuplicateAction.importAsNew, + DuplicateAction.replaceSource, }; @override @@ -419,6 +420,7 @@ class UniversalAdapter implements ImportSourceAdapter { final uddfSelections = UddfImportSelections( dives: resolve(wizard.ImportEntityType.dives), sites: resolve(wizard.ImportEntityType.sites), + siteOverrides: _resolveSiteOverrides(duplicateActions, bundle), buddies: resolve(wizard.ImportEntityType.buddies), equipment: resolve(wizard.ImportEntityType.equipment), trips: resolve(wizard.ImportEntityType.trips), @@ -692,6 +694,27 @@ class UniversalAdapter implements ImportSourceAdapter { // Helpers — import // --------------------------------------------------------------------------- + /// Build a map of import-list index → existing site ID for sites the user + /// chose to overwrite ([DuplicateAction.replaceSource]). + Map _resolveSiteOverrides( + Map> duplicateActions, + ImportBundle bundle, + ) { + final actions = duplicateActions[wizard.ImportEntityType.sites] ?? const {}; + final entityMatches = + bundle.groups[wizard.ImportEntityType.sites]?.entityMatches ?? const {}; + final overrides = {}; + for (final entry in actions.entries) { + if (entry.value == DuplicateAction.replaceSource) { + final existingId = entityMatches[entry.key]?.existingId; + if (existingId != null) { + overrides[entry.key] = existingId; + } + } + } + return overrides; + } + /// Resolve the final selection set for [type] by merging the base /// selections with duplicate actions. Duplicate items whose action is /// [DuplicateAction.importAsNew] are added; items in the base set whose diff --git a/lib/features/import_wizard/presentation/widgets/entity_review_list.dart b/lib/features/import_wizard/presentation/widgets/entity_review_list.dart index 5709bfc06..bb9d7b03a 100644 --- a/lib/features/import_wizard/presentation/widgets/entity_review_list.dart +++ b/lib/features/import_wizard/presentation/widgets/entity_review_list.dart @@ -494,14 +494,12 @@ class _EntityDuplicateCardState extends State<_EntityDuplicateCard> { // the tertiary warning colour so the border reads as "undecided" rather // than implying a skip. The pending branch below also uses tertiary, so // this fallback only matters for the rare non-pending-null case. - final Color borderColor; - if (widget.selectedAction == null) { - borderColor = colorScheme.tertiary; - } else if (isImporting) { - borderColor = Colors.green; - } else { - borderColor = colorScheme.error; - } + final Color borderColor = switch (widget.selectedAction) { + null => colorScheme.tertiary, + DuplicateAction.importAsNew => Colors.green, + DuplicateAction.replaceSource => Colors.blue.shade700, + _ => colorScheme.error, + }; final BorderSide borderSide = widget.isPending ? BorderSide(color: colorScheme.tertiary, width: 1.5) @@ -580,7 +578,7 @@ class _EntityDuplicateCardState extends State<_EntityDuplicateCard> { // Action badge — suppressed when no decision has been made. if (widget.selectedAction != null) ...[ const SizedBox(width: 8), - _SimpleActionBadge(isImporting: isImporting), + _SimpleActionBadge(action: widget.selectedAction!), ], // Expand/collapse chevron (only when comparison data exists) if (widget.entityMatch != null) ...[ @@ -723,6 +721,14 @@ class _EntityComparisonPanel extends StatelessWidget { onPressed: () => onActionChanged(DuplicateAction.importAsNew), ), + _EntityActionButton( + label: 'Overwrite', + subtitle: 'Replace existing entry', + isSelected: selectedAction == DuplicateAction.replaceSource, + color: Colors.blue.shade700, + onPressed: () => + onActionChanged(DuplicateAction.replaceSource), + ), ], ), ], @@ -872,16 +878,18 @@ class _EntityActionButton extends StatelessWidget { } class _SimpleActionBadge extends StatelessWidget { - final bool isImporting; + final DuplicateAction action; - const _SimpleActionBadge({required this.isImporting}); + const _SimpleActionBadge({required this.action}); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final (label, color) = isImporting - ? ('IMPORT', Colors.green.shade700) - : ('SKIP', theme.colorScheme.error); + final (label, color) = switch (action) { + DuplicateAction.importAsNew => ('IMPORT', Colors.green.shade700), + DuplicateAction.replaceSource => ('OVERWRITE', Colors.blue.shade700), + _ => ('SKIP', theme.colorScheme.error), + }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),