From 7a71b3acbe0dc71a7b8d802feed496ae6fe736af Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 6 Jul 2026 10:45:59 -0400 Subject: [PATCH 1/4] refactor(import): remove inert fingerprint/depthTolerance params from matcher path --- .../dive_computer/data/services/dive_import_service.dart | 2 -- .../data/repositories/dive_computer_repository_impl.dart | 1 - .../dive_computer/data/services/dive_import_service_test.dart | 4 ---- 3 files changed, 7 deletions(-) diff --git a/lib/features/dive_computer/data/services/dive_import_service.dart b/lib/features/dive_computer/data/services/dive_import_service.dart index 1b041259b..e8e4efd38 100644 --- a/lib/features/dive_computer/data/services/dive_import_service.dart +++ b/lib/features/dive_computer/data/services/dive_import_service.dart @@ -421,7 +421,6 @@ class DiveImportService { Future detectDuplicate( DownloadedDive dive, { double timeTolerance = 5.0, // minutes - double depthTolerance = 0.5, // meters String? diverId, Map>? sourceKeysCache, }) async { @@ -454,7 +453,6 @@ class DiveImportService { toleranceMinutes: timeTolerance.round(), durationSeconds: dive.durationSeconds, maxDepth: dive.maxDepth, - fingerprint: dive.fingerprint, diverId: diverId, ); 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 694ba5a3a..b4bf1b844 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 @@ -625,7 +625,6 @@ class DiveComputerRepository { int toleranceMinutes = 5, int? durationSeconds, double? maxDepth, - String? fingerprint, String? diverId, }) async { try { diff --git a/test/features/dive_computer/data/services/dive_import_service_test.dart b/test/features/dive_computer/data/services/dive_import_service_test.dart index 2ef9786f6..e3c038cf3 100644 --- a/test/features/dive_computer/data/services/dive_import_service_test.dart +++ b/test/features/dive_computer/data/services/dive_import_service_test.dart @@ -46,7 +46,6 @@ void main() { toleranceMinutes: anyNamed('toleranceMinutes'), durationSeconds: anyNamed('durationSeconds'), maxDepth: anyNamed('maxDepth'), - fingerprint: anyNamed('fingerprint'), diverId: anyNamed('diverId'), ), ).thenAnswer((_) async => null); @@ -391,7 +390,6 @@ void main() { toleranceMinutes: anyNamed('toleranceMinutes'), durationSeconds: anyNamed('durationSeconds'), maxDepth: anyNamed('maxDepth'), - fingerprint: anyNamed('fingerprint'), diverId: anyNamed('diverId'), ), ).thenAnswer( @@ -802,7 +800,6 @@ void main() { toleranceMinutes: anyNamed('toleranceMinutes'), durationSeconds: anyNamed('durationSeconds'), maxDepth: anyNamed('maxDepth'), - fingerprint: anyNamed('fingerprint'), diverId: anyNamed('diverId'), ), ); @@ -862,7 +859,6 @@ void main() { toleranceMinutes: anyNamed('toleranceMinutes'), durationSeconds: anyNamed('durationSeconds'), maxDepth: anyNamed('maxDepth'), - fingerprint: anyNamed('fingerprint'), diverId: anyNamed('diverId'), ), ).thenAnswer( From 336437d4423cf13c46d8ce70fa192e03f9560d18 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 6 Jul 2026 10:47:36 -0400 Subject: [PATCH 2/4] refactor(import): remove dead standalone UDDF import path --- .../data/services/uddf_duplicate_checker.dart | 723 ------------- .../data/services/uddf_parser_service.dart | 50 - .../providers/uddf_import_providers.dart | 540 ---------- .../widgets/uddf_entity_card.dart | 131 --- .../services/import_duplicate_checker.dart | 2 +- .../services/uddf_duplicate_checker_test.dart | 951 ------------------ .../services/uddf_parser_service_test.dart | 154 --- .../providers/uddf_import_providers_test.dart | 268 ----- .../widgets/uddf_entity_card_test.dart | 101 -- 9 files changed, 1 insertion(+), 2919 deletions(-) delete mode 100644 lib/features/dive_import/data/services/uddf_duplicate_checker.dart delete mode 100644 lib/features/dive_import/data/services/uddf_parser_service.dart delete mode 100644 lib/features/dive_import/presentation/providers/uddf_import_providers.dart delete mode 100644 lib/features/dive_import/presentation/widgets/uddf_entity_card.dart delete mode 100644 test/features/dive_import/data/services/uddf_duplicate_checker_test.dart delete mode 100644 test/features/dive_import/data/services/uddf_parser_service_test.dart delete mode 100644 test/features/dive_import/presentation/providers/uddf_import_providers_test.dart delete mode 100644 test/features/dive_import/presentation/widgets/uddf_entity_card_test.dart diff --git a/lib/features/dive_import/data/services/uddf_duplicate_checker.dart b/lib/features/dive_import/data/services/uddf_duplicate_checker.dart deleted file mode 100644 index 55014a3d8..000000000 --- a/lib/features/dive_import/data/services/uddf_duplicate_checker.dart +++ /dev/null @@ -1,723 +0,0 @@ -import 'dart:math' as math; - -import 'package:intl/intl.dart'; -import 'package:submersion/core/constants/enums.dart'; -import 'package:submersion/core/services/export/export_service.dart'; -import 'package:submersion/features/buddies/domain/entities/buddy.dart'; -import 'package:submersion/features/certifications/domain/entities/certification.dart'; -import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; -import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart'; -import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; -import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; -import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; -import 'package:submersion/features/import_wizard/domain/models/entity_match_result.dart'; -import 'package:submersion/features/tags/domain/entities/tag.dart'; -import 'package:submersion/features/trips/domain/entities/trip.dart'; - -/// Result of duplicate checking across all UDDF entity types. -class UddfDuplicateCheckResult { - /// Indices of imported items that match existing entities. - final Set duplicateTrips; - final Set duplicateSites; - final Set duplicateEquipment; - final Set duplicateBuddies; - final Set duplicateDiveCenters; - final Set duplicateCertifications; - final Set duplicateTags; - final Set duplicateDiveTypes; - - /// Dive duplicate info: imported index -> best match result. - /// Only includes indices that have a possible or probable match. - final Map diveMatches; - - /// Entity match results for non-dive duplicates, keyed by entity type name. - final Map> entityMatches; - - const UddfDuplicateCheckResult({ - this.duplicateTrips = const {}, - this.duplicateSites = const {}, - this.duplicateEquipment = const {}, - this.duplicateBuddies = const {}, - this.duplicateDiveCenters = const {}, - this.duplicateCertifications = const {}, - this.duplicateTags = const {}, - this.duplicateDiveTypes = const {}, - this.diveMatches = const {}, - this.entityMatches = const {}, - }); - - bool get hasDuplicates => - duplicateTrips.isNotEmpty || - duplicateSites.isNotEmpty || - duplicateEquipment.isNotEmpty || - duplicateBuddies.isNotEmpty || - duplicateDiveCenters.isNotEmpty || - duplicateCertifications.isNotEmpty || - duplicateTags.isNotEmpty || - duplicateDiveTypes.isNotEmpty || - diveMatches.isNotEmpty; - - int get totalDuplicates => - duplicateTrips.length + - duplicateSites.length + - duplicateEquipment.length + - duplicateBuddies.length + - duplicateDiveCenters.length + - duplicateCertifications.length + - duplicateTags.length + - duplicateDiveTypes.length + - diveMatches.length; - - /// Get entity matches for a specific type by its key name. - Map? entityMatchesFor(String typeKey) => - entityMatches[typeKey]; -} - -/// Checks UDDF import data against existing entities for duplicates. -/// -/// Uses case-insensitive name matching for most entity types, with -/// secondary criteria for sites (lat/lon proximity), equipment (type), -/// and certifications (agency). Dives use fuzzy [DiveMatcher] scoring. -class UddfDuplicateChecker { - const UddfDuplicateChecker(); - - static final _dateFormatter = DateFormat('MMM d, yyyy'); - - /// Check all entity types in [importData] against existing entities. - /// - /// Returns a [UddfDuplicateCheckResult] with the indices of imported - /// items that match existing data (and should be auto-deselected). - UddfDuplicateCheckResult check({ - required UddfImportResult importData, - required List existingTrips, - required List existingSites, - required List existingEquipment, - required List existingBuddies, - required List existingDiveCenters, - required List existingCertifications, - required List existingTags, - required List existingDiveTypes, - required List existingDives, - DiveMatcher matcher = const DiveMatcher(), - }) { - final allEntityMatches = >{}; - - final tripResult = _checkTripDuplicates(importData.trips, existingTrips); - if (tripResult.matches.isNotEmpty) { - allEntityMatches['trips'] = tripResult.matches; - } - - final siteResult = _checkSiteDuplicates(importData.sites, existingSites); - if (siteResult.matches.isNotEmpty) { - allEntityMatches['sites'] = siteResult.matches; - } - - final equipmentResult = _checkEquipmentDuplicates( - importData.equipment, - existingEquipment, - ); - if (equipmentResult.matches.isNotEmpty) { - allEntityMatches['equipment'] = equipmentResult.matches; - } - - final buddyResult = _checkBuddyDuplicates( - importData.buddies, - existingBuddies, - ); - if (buddyResult.matches.isNotEmpty) { - allEntityMatches['buddies'] = buddyResult.matches; - } - - final diveCenterResult = _checkDiveCenterDuplicates( - importData.diveCenters, - existingDiveCenters, - ); - if (diveCenterResult.matches.isNotEmpty) { - allEntityMatches['diveCenters'] = diveCenterResult.matches; - } - - final certResult = _checkCertificationDuplicates( - importData.certifications, - existingCertifications, - ); - if (certResult.matches.isNotEmpty) { - allEntityMatches['certifications'] = certResult.matches; - } - - final tagResult = _checkTagDuplicates(importData.tags, existingTags); - if (tagResult.matches.isNotEmpty) { - allEntityMatches['tags'] = tagResult.matches; - } - - final diveTypeResult = _checkDiveTypeDuplicates( - importData.customDiveTypes, - existingDiveTypes, - ); - if (diveTypeResult.matches.isNotEmpty) { - allEntityMatches['diveTypes'] = diveTypeResult.matches; - } - - return UddfDuplicateCheckResult( - duplicateTrips: tripResult.indices, - duplicateSites: siteResult.indices, - duplicateEquipment: equipmentResult.indices, - duplicateBuddies: buddyResult.indices, - duplicateDiveCenters: diveCenterResult.indices, - duplicateCertifications: certResult.indices, - duplicateTags: tagResult.indices, - duplicateDiveTypes: diveTypeResult.indices, - diveMatches: _checkDiveDuplicates( - importData.dives, - existingDives, - matcher, - ), - entityMatches: allEntityMatches, - ); - } - - // ======================== Trip Matching ======================== - - _EntityCheckResult _checkTripDuplicates( - List> importedItems, - List existingTrips, - ) { - final existingByLower = {}; - for (final trip in existingTrips) { - existingByLower[trip.name.toLowerCase()] = trip; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedItems.length; i++) { - final name = importedItems[i]['name'] as String?; - if (name == null) continue; - - final existing = existingByLower[name.toLowerCase()]; - if (existing != null) { - indices.add(i); - matches[i] = _buildTripMatch(importedItems[i], existing); - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - EntityMatchResult _buildTripMatch( - Map incoming, - Trip existing, - ) { - final startDate = incoming['startDate'] as DateTime?; - final endDate = incoming['endDate'] as DateTime?; - final location = incoming['location'] as String?; - - return EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: { - 'Name': existing.name, - 'Start Date': _dateFormatter.format(existing.startDate), - 'End Date': _dateFormatter.format(existing.endDate), - 'Location': existing.location, - }, - incomingFields: { - 'Name': incoming['name'] as String?, - 'Start Date': startDate != null - ? _dateFormatter.format(startDate) - : null, - 'End Date': endDate != null ? _dateFormatter.format(endDate) : null, - 'Location': location, - }, - ); - } - - // ======================== Buddy Matching ======================== - - _EntityCheckResult _checkBuddyDuplicates( - List> importedItems, - List existingBuddies, - ) { - final existingByLower = {}; - for (final buddy in existingBuddies) { - existingByLower[buddy.name.toLowerCase()] = buddy; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedItems.length; i++) { - final name = importedItems[i]['name'] as String?; - if (name == null) continue; - - final existing = existingByLower[name.toLowerCase()]; - if (existing != null) { - indices.add(i); - matches[i] = EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: { - 'Name': existing.name, - 'Email': existing.email, - 'Phone': existing.phone, - }, - incomingFields: { - 'Name': name, - 'Email': importedItems[i]['email'] as String?, - 'Phone': importedItems[i]['phone'] as String?, - }, - ); - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - // ======================== Tag Matching ======================== - - _EntityCheckResult _checkTagDuplicates( - List> importedItems, - List existingTags, - ) { - final existingByLower = {}; - for (final tag in existingTags) { - existingByLower[tag.name.toLowerCase()] = tag; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedItems.length; i++) { - final name = importedItems[i]['name'] as String?; - if (name == null) continue; - - final existing = existingByLower[name.toLowerCase()]; - if (existing != null) { - indices.add(i); - matches[i] = EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: {'Name': existing.name}, - incomingFields: {'Name': name}, - ); - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - // ======================== Dive Center Matching ======================== - - _EntityCheckResult _checkDiveCenterDuplicates( - List> importedItems, - List existingDiveCenters, - ) { - final existingByLower = {}; - for (final center in existingDiveCenters) { - existingByLower[center.name.toLowerCase()] = center; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedItems.length; i++) { - final name = importedItems[i]['name'] as String?; - if (name == null) continue; - - final existing = existingByLower[name.toLowerCase()]; - if (existing != null) { - indices.add(i); - matches[i] = EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: { - 'Name': existing.name, - 'Location': existing.fullLocationString, - 'Phone': existing.phone, - 'Email': existing.email, - }, - incomingFields: { - 'Name': name, - 'Location': - importedItems[i]['location'] as String? ?? - importedItems[i]['country'] as String?, - 'Phone': importedItems[i]['phone'] as String?, - 'Email': importedItems[i]['email'] as String?, - }, - ); - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - // ======================== Site Matching ======================== - - _EntityCheckResult _checkSiteDuplicates( - List> importedSites, - List existingSites, - ) { - final existingByNameLower = {}; - for (final site in existingSites) { - existingByNameLower[site.name.toLowerCase()] = site; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedSites.length; i++) { - final name = importedSites[i]['name'] as String?; - - // Check name match first. - if (name != null) { - final existing = existingByNameLower[name.toLowerCase()]; - if (existing != null) { - indices.add(i); - matches[i] = _buildSiteMatch(importedSites[i], existing); - continue; - } - } - - // Secondary: lat/lon proximity (within 100 meters) - final lat = importedSites[i]['latitude'] as double?; - final lon = importedSites[i]['longitude'] as double?; - if (lat != null && lon != null) { - for (final existing in existingSites) { - if (existing.location != null) { - final distance = _haversineDistance( - lat, - lon, - existing.location!.latitude, - existing.location!.longitude, - ); - if (distance <= 100) { - indices.add(i); - matches[i] = _buildSiteMatch(importedSites[i], existing); - break; - } - } - } - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - EntityMatchResult _buildSiteMatch( - Map incoming, - DiveSite existing, - ) { - final lat = incoming['latitude'] as double?; - final lon = incoming['longitude'] as double?; - final incomingLocation = (lat != null && lon != null) - ? '${lat.toStringAsFixed(4)}, ${lon.toStringAsFixed(4)}' - : incoming['location'] as String?; - - final existingLocation = existing.location?.toString(); - - final maxDepth = incoming['maxDepth'] as double?; - final existingMaxDepth = existing.maxDepth; - - return EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: { - 'Name': existing.name, - 'Location': existingLocation, - 'Max Depth': existingMaxDepth != null - ? '${existingMaxDepth.toStringAsFixed(1)}m' - : null, - 'Country': existing.country, - 'Region': existing.region, - }, - incomingFields: { - 'Name': incoming['name'] as String?, - 'Location': incomingLocation, - 'Max Depth': maxDepth != null - ? '${maxDepth.toStringAsFixed(1)}m' - : null, - 'Country': incoming['country'] as String?, - 'Region': incoming['region'] as String?, - }, - ); - } - - // ======================== Equipment Matching ======================== - - _EntityCheckResult _checkEquipmentDuplicates( - List> importedEquipment, - List existingEquipment, - ) { - final existingByKey = {}; - for (final item in existingEquipment) { - existingByKey['${item.name.toLowerCase()}|${item.type.name.toLowerCase()}'] = - item; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedEquipment.length; i++) { - final name = importedEquipment[i]['name'] as String?; - if (name == null) continue; - - final typeValue = importedEquipment[i]['type']; - String typeStr; - if (typeValue is EquipmentType) { - typeStr = typeValue.name.toLowerCase(); - } else if (typeValue is String) { - typeStr = typeValue.toLowerCase(); - } else { - typeStr = 'other'; - } - - final key = '${name.toLowerCase()}|$typeStr'; - final existing = existingByKey[key]; - if (existing != null) { - indices.add(i); - - String? typeDisplay; - if (typeValue is EquipmentType) { - typeDisplay = typeValue.displayName; - } else if (typeValue is String) { - typeDisplay = typeValue; - } - - matches[i] = EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: { - 'Name': existing.name, - 'Type': existing.type.displayName, - 'Brand': existing.brand, - 'Model': existing.model, - 'Serial': existing.serialNumber, - }, - incomingFields: { - 'Name': name, - 'Type': typeDisplay, - 'Brand': importedEquipment[i]['brand'] as String?, - 'Model': importedEquipment[i]['model'] as String?, - 'Serial': importedEquipment[i]['serialNumber'] as String?, - }, - ); - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - // ======================== Certification Matching ======================== - - _EntityCheckResult _checkCertificationDuplicates( - List> importedCerts, - List existingCerts, - ) { - final existingByKey = {}; - for (final cert in existingCerts) { - existingByKey['${cert.name.toLowerCase()}|${cert.agency.name.toLowerCase()}'] = - cert; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedCerts.length; i++) { - final name = importedCerts[i]['name'] as String?; - if (name == null) continue; - - final agencyValue = importedCerts[i]['agency']; - String agencyStr; - if (agencyValue is CertificationAgency) { - agencyStr = agencyValue.name.toLowerCase(); - } else if (agencyValue is String) { - agencyStr = agencyValue.toLowerCase(); - } else { - continue; - } - - final key = '${name.toLowerCase()}|$agencyStr'; - final existing = existingByKey[key]; - if (existing != null) { - indices.add(i); - - String? agencyDisplay; - if (agencyValue is CertificationAgency) { - agencyDisplay = agencyValue.displayName; - } else if (agencyValue is String) { - agencyDisplay = agencyValue; - } - - final date = - importedCerts[i]['date'] as DateTime? ?? - importedCerts[i]['issueDate'] as DateTime?; - - matches[i] = EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: { - 'Name': existing.name, - 'Agency': existing.agency.displayName, - 'Date': existing.issueDate != null - ? _dateFormatter.format(existing.issueDate!) - : null, - }, - incomingFields: { - 'Name': name, - 'Agency': agencyDisplay, - 'Date': date != null ? _dateFormatter.format(date) : null, - }, - ); - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - // ======================== Dive Type Matching ======================== - - _EntityCheckResult _checkDiveTypeDuplicates( - List> importedTypes, - List existingTypes, - ) { - final existingByName = {}; - final existingById = {}; - for (final t in existingTypes) { - existingByName[t.name.toLowerCase()] = t; - existingById[t.id.toLowerCase()] = t; - } - - final indices = {}; - final matches = {}; - - for (var i = 0; i < importedTypes.length; i++) { - final name = importedTypes[i]['name'] as String?; - final id = importedTypes[i]['id'] as String?; - - DiveTypeEntity? existing; - if (name != null) { - existing = existingByName[name.toLowerCase()]; - } - if (existing == null && id != null) { - existing = existingById[id.toLowerCase()]; - } - - if (existing != null) { - indices.add(i); - matches[i] = EntityMatchResult( - existingId: existing.id, - existingName: existing.name, - existingFields: {'Name': existing.name}, - incomingFields: {'Name': name}, - ); - } - } - - return _EntityCheckResult(indices: indices, matches: matches); - } - - // ======================== Dive Matching ======================== - - /// Check for dive duplicates using fuzzy [DiveMatcher] scoring. - Map _checkDiveDuplicates( - List> importedDives, - List existingDives, - DiveMatcher matcher, - ) { - if (existingDives.isEmpty) return {}; - - final matches = {}; - - for (var i = 0; i < importedDives.length; i++) { - final diveData = importedDives[i]; - final dateTime = diveData['dateTime'] as DateTime?; - if (dateTime == null) continue; - - final maxDepth = diveData['maxDepth'] as double? ?? 0; - final runtime = diveData['runtime'] as Duration?; - final duration = diveData['duration'] as Duration?; - final durationSeconds = (runtime ?? duration)?.inSeconds ?? 0; - - DiveMatchResult? bestMatch; - for (final existing in existingDives) { - final existingDurationSeconds = _diveSeconds(existing); - - final score = matcher.calculateMatchScore( - wearableStartTime: dateTime, - wearableMaxDepth: maxDepth, - wearableDurationSeconds: durationSeconds, - existingStartTime: existing.dateTime, - existingMaxDepth: existing.maxDepth ?? 0, - existingDurationSeconds: existingDurationSeconds, - ); - - if (matcher.isPossibleDuplicate(score)) { - if (bestMatch == null || score > bestMatch.score) { - bestMatch = DiveMatchResult( - diveId: existing.id, - score: score, - timeDifferenceMs: dateTime - .difference(existing.dateTime) - .inMilliseconds - .abs(), - depthDifferenceMeters: existing.maxDepth != null - ? (maxDepth - existing.maxDepth!).abs() - : null, - durationDifferenceSeconds: existingDurationSeconds > 0 - ? (durationSeconds - existingDurationSeconds).abs() - : null, - siteName: existing.site?.name, - ); - } - } - } - - if (bestMatch != null) { - matches[i] = bestMatch; - } - } - - return matches; - } - - int _diveSeconds(Dive dive) { - // Use runtime (total time), not duration (bottom time), to match - // the incoming side which uses runtime ?? duration. - if (dive.runtime != null) return dive.runtime!.inSeconds; - if (dive.exitTime != null && dive.entryTime != null) { - return dive.exitTime!.difference(dive.entryTime!).inSeconds; - } - if (dive.bottomTime != null) return dive.bottomTime!.inSeconds; - return 0; - } - - /// Calculate haversine distance in meters between two lat/lon points. - static double _haversineDistance( - double lat1, - double lon1, - double lat2, - double lon2, - ) { - const earthRadius = 6371000.0; - final dLat = _toRadians(lat2 - lat1); - final dLon = _toRadians(lon2 - lon1); - final a = - math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(_toRadians(lat1)) * - math.cos(_toRadians(lat2)) * - math.sin(dLon / 2) * - math.sin(dLon / 2); - final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); - return earthRadius * c; - } - - static double _toRadians(double degrees) => degrees * math.pi / 180; -} - -/// Internal result from an entity-type duplicate check. -class _EntityCheckResult { - final Set indices; - final Map matches; - - const _EntityCheckResult({required this.indices, required this.matches}); -} diff --git a/lib/features/dive_import/data/services/uddf_parser_service.dart b/lib/features/dive_import/data/services/uddf_parser_service.dart deleted file mode 100644 index 4fe7f269c..000000000 --- a/lib/features/dive_import/data/services/uddf_parser_service.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:io'; - -import 'package:submersion/core/services/export/export_service.dart'; - -/// Thin wrapper around [ExportService.importAllDataFromUddf] that handles -/// file I/O and extension validation. -class UddfParserService { - final ExportService _exportService; - - const UddfParserService(this._exportService); - - /// Valid file extensions for UDDF import. - static const validExtensions = {'uddf', 'xml'}; - - /// Parse a UDDF file at [filePath] and return the structured import result. - /// - /// Throws [UddfParseException] if the file extension is invalid - /// or the file cannot be read/parsed. - Future parseFile(String filePath) async { - final extension = filePath.split('.').last.toLowerCase(); - if (!validExtensions.contains(extension)) { - throw const UddfParseException('Please select a UDDF or XML file'); - } - - final file = File(filePath); - if (!await file.exists()) { - throw const UddfParseException('File not found'); - } - - final content = await file.readAsString(); - return parseContent(content); - } - - /// Parse UDDF content string directly. - /// - /// Useful for testing or when the content is already in memory. - Future parseContent(String uddfContent) async { - return _exportService.importAllDataFromUddf(uddfContent); - } -} - -/// Exception thrown when UDDF parsing fails. -class UddfParseException implements Exception { - final String message; - - const UddfParseException(this.message); - - @override - String toString() => 'UddfParseException: $message'; -} diff --git a/lib/features/dive_import/presentation/providers/uddf_import_providers.dart b/lib/features/dive_import/presentation/providers/uddf_import_providers.dart deleted file mode 100644 index 420d73d6e..000000000 --- a/lib/features/dive_import/presentation/providers/uddf_import_providers.dart +++ /dev/null @@ -1,540 +0,0 @@ -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/core/services/export/export_service.dart'; -import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; -import 'package:submersion/features/settings/presentation/providers/export_providers.dart'; -import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; -import 'package:submersion/features/courses/presentation/providers/course_providers.dart'; -import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_duplicate_checker.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_parser_service.dart'; -import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; -import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; -import 'package:submersion/features/dive_types/presentation/providers/dive_type_providers.dart'; -import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; -import 'package:submersion/features/equipment/presentation/providers/equipment_providers.dart'; -import 'package:submersion/features/equipment/presentation/providers/equipment_set_providers.dart'; -import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; -import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; -import 'package:submersion/features/tank_presets/domain/services/default_tank_preset_resolver.dart'; -import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; -import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; - -// ============================================================================ -// Entity type identifiers for selection management -// ============================================================================ - -/// Identifies entity types in the UDDF import wizard. -enum UddfEntityType { - trips, - equipment, - buddies, - diveCenters, - certifications, - courses, - tags, - diveTypes, - sites, - equipmentSets, - dives, -} - -// ============================================================================ -// State -// ============================================================================ - -/// State for the UDDF import wizard. -class UddfImportState { - const UddfImportState({ - this.currentStep = 0, - this.isLoading = false, - this.isImporting = false, - this.error, - this.parsedData, - this.duplicateCheckResult, - this.selectedTrips = const {}, - this.selectedEquipment = const {}, - this.selectedBuddies = const {}, - this.selectedDiveCenters = const {}, - this.selectedCertifications = const {}, - this.selectedTags = const {}, - this.selectedDiveTypes = const {}, - this.selectedSites = const {}, - this.selectedEquipmentSets = const {}, - this.selectedDives = const {}, - this.selectedCourses = const {}, - this.importResult, - this.importPhase = '', - this.importCurrent = 0, - this.importTotal = 0, - }); - - /// Current wizard step: 0=file, 1=review, 2=importing, 3=summary. - final int currentStep; - final bool isLoading; - final bool isImporting; - final String? error; - - /// Parsed UDDF data (set after step 0). - final UddfImportResult? parsedData; - - /// Duplicate check results (set after step 0, before step 1). - final UddfDuplicateCheckResult? duplicateCheckResult; - - /// Selected indices per entity type. - final Set selectedTrips; - final Set selectedEquipment; - final Set selectedBuddies; - final Set selectedDiveCenters; - final Set selectedCertifications; - final Set selectedTags; - final Set selectedDiveTypes; - final Set selectedSites; - final Set selectedEquipmentSets; - final Set selectedDives; - final Set selectedCourses; - - /// Import result (set after step 2). - final UddfEntityImportResult? importResult; - - /// Progress tracking. - final String importPhase; - final int importCurrent; - final int importTotal; - - UddfImportState copyWith({ - int? currentStep, - bool? isLoading, - bool? isImporting, - String? error, - bool clearError = false, - UddfImportResult? parsedData, - UddfDuplicateCheckResult? duplicateCheckResult, - Set? selectedTrips, - Set? selectedEquipment, - Set? selectedBuddies, - Set? selectedDiveCenters, - Set? selectedCertifications, - Set? selectedTags, - Set? selectedDiveTypes, - Set? selectedSites, - Set? selectedEquipmentSets, - Set? selectedDives, - Set? selectedCourses, - UddfEntityImportResult? importResult, - String? importPhase, - int? importCurrent, - int? importTotal, - }) { - return UddfImportState( - currentStep: currentStep ?? this.currentStep, - isLoading: isLoading ?? this.isLoading, - isImporting: isImporting ?? this.isImporting, - error: clearError ? null : (error ?? this.error), - parsedData: parsedData ?? this.parsedData, - duplicateCheckResult: duplicateCheckResult ?? this.duplicateCheckResult, - selectedTrips: selectedTrips ?? this.selectedTrips, - selectedEquipment: selectedEquipment ?? this.selectedEquipment, - selectedBuddies: selectedBuddies ?? this.selectedBuddies, - selectedDiveCenters: selectedDiveCenters ?? this.selectedDiveCenters, - selectedCertifications: - selectedCertifications ?? this.selectedCertifications, - selectedTags: selectedTags ?? this.selectedTags, - selectedDiveTypes: selectedDiveTypes ?? this.selectedDiveTypes, - selectedSites: selectedSites ?? this.selectedSites, - selectedEquipmentSets: - selectedEquipmentSets ?? this.selectedEquipmentSets, - selectedDives: selectedDives ?? this.selectedDives, - selectedCourses: selectedCourses ?? this.selectedCourses, - importResult: importResult ?? this.importResult, - importPhase: importPhase ?? this.importPhase, - importCurrent: importCurrent ?? this.importCurrent, - importTotal: importTotal ?? this.importTotal, - ); - } - - /// Get the selection set for a given entity type. - Set selectionFor(UddfEntityType type) { - return switch (type) { - UddfEntityType.trips => selectedTrips, - UddfEntityType.equipment => selectedEquipment, - UddfEntityType.buddies => selectedBuddies, - UddfEntityType.diveCenters => selectedDiveCenters, - UddfEntityType.certifications => selectedCertifications, - UddfEntityType.courses => selectedCourses, - UddfEntityType.tags => selectedTags, - UddfEntityType.diveTypes => selectedDiveTypes, - UddfEntityType.sites => selectedSites, - UddfEntityType.equipmentSets => selectedEquipmentSets, - UddfEntityType.dives => selectedDives, - }; - } - - /// Total count of items in parsedData for a given entity type. - int totalCountFor(UddfEntityType type) { - final data = parsedData; - if (data == null) return 0; - return switch (type) { - UddfEntityType.trips => data.trips.length, - UddfEntityType.equipment => data.equipment.length, - UddfEntityType.buddies => data.buddies.length, - UddfEntityType.diveCenters => data.diveCenters.length, - UddfEntityType.certifications => data.certifications.length, - UddfEntityType.courses => data.courses.length, - UddfEntityType.tags => data.tags.length, - UddfEntityType.diveTypes => data.customDiveTypes.length, - UddfEntityType.sites => data.sites.length, - UddfEntityType.equipmentSets => data.equipmentSets.length, - UddfEntityType.dives => data.dives.length, - }; - } - - /// Total selected items across all entity types. - int get totalSelected => - selectedTrips.length + - selectedEquipment.length + - selectedBuddies.length + - selectedDiveCenters.length + - selectedCertifications.length + - selectedTags.length + - selectedDiveTypes.length + - selectedSites.length + - selectedEquipmentSets.length + - selectedDives.length + - selectedCourses.length; - - /// Build [UddfImportSelections] from current selection state. - UddfImportSelections toSelections() { - return UddfImportSelections( - trips: selectedTrips, - equipment: selectedEquipment, - buddies: selectedBuddies, - diveCenters: selectedDiveCenters, - certifications: selectedCertifications, - tags: selectedTags, - diveTypes: selectedDiveTypes, - sites: selectedSites, - equipmentSets: selectedEquipmentSets, - dives: selectedDives, - courses: selectedCourses, - ); - } -} - -// ============================================================================ -// Notifier -// ============================================================================ - -/// Manages the UDDF import wizard flow. -class UddfImportNotifier extends StateNotifier { - UddfImportNotifier(this._ref) : super(const UddfImportState()); - - final Ref _ref; - - /// Step 0: Pick a file, parse it, run duplicate check, advance to step 1. - Future pickAndParseFile() async { - state = state.copyWith(isLoading: true, clearError: true); - - try { - // Use FileType.any on iOS/macOS since custom extensions don't work - // reliably in sandboxed apps. - final result = await FilePicker.pickFiles( - type: FileType.any, - allowMultiple: false, - ); - - if (result == null || result.files.isEmpty) { - state = state.copyWith(isLoading: false); - return; - } - - final pickedFile = result.files.first; - - // Validate extension using PlatformFile.extension (derived from the - // original filename) rather than the full path, which may lose the - // extension on macOS/iOS sandboxed apps. - final ext = pickedFile.extension?.toLowerCase(); - if (ext != 'uddf' && ext != 'xml') { - state = state.copyWith( - isLoading: false, - error: 'Please select a UDDF or XML file', - ); - return; - } - - final filePath = pickedFile.path; - if (filePath == null) { - state = state.copyWith( - isLoading: false, - error: 'Could not access file', - ); - return; - } - - await parseFile(filePath); - } catch (e) { - state = state.copyWith( - isLoading: false, - error: 'Failed to pick file: $e', - ); - } - } - - /// Parse a UDDF file at [filePath], run duplicate check, and set up - /// default selections (all selected except duplicates). - /// - /// Reads the file content directly and uses [UddfParserService.parseContent] - /// to avoid the path-based extension check, which can fail on macOS/iOS - /// sandboxed apps where the file picker returns temporary paths. - Future parseFile(String filePath) async { - state = state.copyWith(isLoading: true, clearError: true); - - try { - final file = File(filePath); - if (!await file.exists()) { - state = state.copyWith(isLoading: false, error: 'File not found'); - return; - } - final content = await file.readAsString(); - - final exportService = _ref.read(exportServiceProvider); - final parser = UddfParserService(exportService); - final parsed = await parser.parseContent(content); - - // Attach the source filename so it is available when creating - // DiveDataSource records during import. - final fileName = filePath.split('/').last; - final data = parsed.copyWithSourceFileName(fileName); - - // Run duplicate check against existing entities - final dupResult = await _checkDuplicates(data); - - // Build default selections: all selected, minus duplicates - final selections = UddfImportSelections.selectAll(data); - - state = state.copyWith( - isLoading: false, - parsedData: data, - duplicateCheckResult: dupResult, - selectedTrips: selections.trips.difference(dupResult.duplicateTrips), - selectedEquipment: selections.equipment.difference( - dupResult.duplicateEquipment, - ), - selectedBuddies: selections.buddies.difference( - dupResult.duplicateBuddies, - ), - selectedDiveCenters: selections.diveCenters.difference( - dupResult.duplicateDiveCenters, - ), - selectedCertifications: selections.certifications.difference( - dupResult.duplicateCertifications, - ), - selectedTags: selections.tags.difference(dupResult.duplicateTags), - selectedDiveTypes: selections.diveTypes.difference( - dupResult.duplicateDiveTypes, - ), - selectedSites: selections.sites.difference(dupResult.duplicateSites), - selectedEquipmentSets: selections.equipmentSets, - selectedCourses: selections.courses, - selectedDives: selections.dives.difference( - Set.from(dupResult.diveMatches.keys), - ), - currentStep: 1, - ); - } on UddfParseException catch (e) { - state = state.copyWith(isLoading: false, error: e.message); - } catch (e) { - state = state.copyWith( - isLoading: false, - error: 'Failed to parse file: $e', - ); - } - } - - /// Run duplicate checking against all existing entities. - Future _checkDuplicates( - UddfImportResult data, - ) async { - const checker = UddfDuplicateChecker(); - - final existingTrips = await _ref.read(allTripsProvider.future); - final existingSites = await _ref.read(sitesProvider.future); - final existingEquipment = await _ref.read(allEquipmentProvider.future); - final existingBuddies = await _ref.read(allBuddiesProvider.future); - final existingDiveCenters = await _ref.read(allDiveCentersProvider.future); - final existingCertifications = await _ref.read( - allCertificationsProvider.future, - ); - final existingTags = await _ref.read(tagsProvider.future); - final existingDiveTypes = await _ref.read(diveTypesProvider.future); - final diveRepo = _ref.read(diveRepositoryProvider); - final existingDives = await diveRepo.getAllDives(); - - return checker.check( - importData: data, - existingTrips: existingTrips, - existingSites: existingSites, - existingEquipment: existingEquipment, - existingBuddies: existingBuddies, - existingDiveCenters: existingDiveCenters, - existingCertifications: existingCertifications, - existingTags: existingTags, - existingDiveTypes: existingDiveTypes, - existingDives: existingDives, - ); - } - - /// Toggle selection of a single item. - void toggleSelection(UddfEntityType type, int index) { - final current = state.selectionFor(type); - final updated = Set.from(current); - if (updated.contains(index)) { - updated.remove(index); - } else { - updated.add(index); - } - _updateSelection(type, updated); - } - - /// Select all items of a given entity type. - void selectAll(UddfEntityType type) { - final count = state.totalCountFor(type); - _updateSelection(type, Set.from(List.generate(count, (i) => i))); - } - - /// Deselect all items of a given entity type. - void deselectAll(UddfEntityType type) { - _updateSelection(type, const {}); - } - - void _updateSelection(UddfEntityType type, Set selection) { - state = switch (type) { - UddfEntityType.trips => state.copyWith(selectedTrips: selection), - UddfEntityType.equipment => state.copyWith(selectedEquipment: selection), - UddfEntityType.buddies => state.copyWith(selectedBuddies: selection), - UddfEntityType.diveCenters => state.copyWith( - selectedDiveCenters: selection, - ), - UddfEntityType.certifications => state.copyWith( - selectedCertifications: selection, - ), - UddfEntityType.courses => state.copyWith(selectedCourses: selection), - UddfEntityType.tags => state.copyWith(selectedTags: selection), - UddfEntityType.diveTypes => state.copyWith(selectedDiveTypes: selection), - UddfEntityType.sites => state.copyWith(selectedSites: selection), - UddfEntityType.equipmentSets => state.copyWith( - selectedEquipmentSets: selection, - ), - UddfEntityType.dives => state.copyWith(selectedDives: selection), - }; - } - - /// Step 1->2->3: Perform the import with current selections. - Future performImport() async { - final data = state.parsedData; - if (data == null) return; - - state = state.copyWith(currentStep: 2, isImporting: true, clearError: true); - - try { - final currentDiver = await _ref.read(currentDiverProvider.future); - if (currentDiver == null) { - state = state.copyWith( - isImporting: false, - error: 'Please create a diver profile before importing', - ); - return; - } - - final repos = ImportRepositories( - tripRepository: _ref.read(tripRepositoryProvider), - equipmentRepository: _ref.read(equipmentRepositoryProvider), - equipmentSetRepository: _ref.read(equipmentSetRepositoryProvider), - buddyRepository: _ref.read(buddyRepositoryProvider), - diveCenterRepository: _ref.read(diveCenterRepositoryProvider), - certificationRepository: _ref.read(certificationRepositoryProvider), - tagRepository: _ref.read(tagRepositoryProvider), - diveTypeRepository: _ref.read(diveTypeRepositoryProvider), - siteRepository: _ref.read(siteRepositoryProvider), - diveRepository: _ref.read(diveRepositoryProvider), - tankPressureRepository: _ref.read(tankPressureRepositoryProvider), - courseRepository: _ref.read(courseRepositoryProvider), - ); - - final settings = _ref.read(settingsProvider); - final resolver = DefaultTankPresetResolver( - repository: _ref.read(tankPresetRepositoryProvider), - ); - final defaultTankPreset = await resolver.resolve( - settings.defaultTankPreset, - ); - final importer = UddfEntityImporter( - defaultTankPreset: defaultTankPreset, - defaultStartPressure: settings.defaultStartPressure, - applyDefaultTankToImports: settings.applyDefaultTankToImports, - ); - final result = await importer.import( - data: data, - selections: state.toSelections(), - repositories: repos, - diverId: currentDiver.id, - onProgress: (phase, current, total) { - state = state.copyWith( - importPhase: phase.name, - importCurrent: current, - importTotal: total, - ); - }, - ); - - // Invalidate providers so lists refresh - _invalidateProviders(); - - state = state.copyWith( - currentStep: 3, - isImporting: false, - importResult: result, - ); - } catch (e) { - state = state.copyWith(isImporting: false, error: 'Import failed: $e'); - } - } - - void _invalidateProviders() { - _ref.invalidate(sitesProvider); - _ref.invalidate(sitesWithCountsProvider); - _ref.invalidate(siteListNotifierProvider); - _ref.invalidate(allBuddiesProvider); - _ref.invalidate(allEquipmentProvider); - _ref.invalidate(activeEquipmentProvider); - _ref.invalidate(retiredEquipmentProvider); - _ref.invalidate(serviceDueEquipmentProvider); - _ref.invalidate(equipmentListNotifierProvider); - _ref.invalidate(equipmentSetsProvider); - _ref.invalidate(allTripsProvider); - _ref.invalidate(allDiveCentersProvider); - _ref.invalidate(allCertificationsProvider); - _ref.invalidate(diveTypesProvider); - _ref.invalidate(tagsProvider); - _ref.invalidate(diveListNotifierProvider); - _ref.invalidate(paginatedDiveListProvider); - _ref.invalidate(nextDiveNumberProvider); - } - - /// Reset to initial state. - void reset() { - state = const UddfImportState(); - } -} - -// ============================================================================ -// Provider -// ============================================================================ - -final uddfImportNotifierProvider = - StateNotifierProvider((ref) { - return UddfImportNotifier(ref); - }); diff --git a/lib/features/dive_import/presentation/widgets/uddf_entity_card.dart b/lib/features/dive_import/presentation/widgets/uddf_entity_card.dart deleted file mode 100644 index 749f82b0e..000000000 --- a/lib/features/dive_import/presentation/widgets/uddf_entity_card.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; - -/// Card displaying a non-dive UDDF entity for selection in the import wizard. -/// -/// Shows entity name, optional subtitle, icon, selection checkbox, and -/// optional duplicate badge. Used for trips, sites, equipment, buddies, -/// dive centers, certifications, tags, and dive types. -class UddfEntityCard extends StatelessWidget { - const UddfEntityCard({ - super.key, - required this.name, - this.subtitle, - required this.icon, - required this.isSelected, - required this.onToggle, - this.isDuplicate = false, - }); - - final String name; - final String? subtitle; - final IconData icon; - final bool isSelected; - final VoidCallback onToggle; - final bool isDuplicate; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Card( - elevation: isSelected ? 2 : 0, - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - child: Semantics( - button: true, - label: context.l10n.diveImport_uddf_toggleEntitySelection(name), - child: InkWell( - onTap: onToggle, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - _buildCheckbox(colorScheme), - const SizedBox(width: 12), - Icon(icon, size: 20, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), - Text( - subtitle!, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - if (isDuplicate) ...[ - const SizedBox(width: 8), - _buildDuplicateBadge(context, colorScheme), - ], - ], - ), - ), - ), - ), - ); - } - - Widget _buildCheckbox(ColorScheme colorScheme) { - return Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: isSelected ? colorScheme.primary : colorScheme.outline, - width: 2, - ), - color: isSelected ? colorScheme.primary : Colors.transparent, - ), - child: isSelected - ? Icon(Icons.check, size: 16, color: colorScheme.onPrimary) - : null, - ); - } - - Widget _buildDuplicateBadge(BuildContext context, ColorScheme colorScheme) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.warning_amber_rounded, size: 14, color: colorScheme.error), - const SizedBox(width: 4), - Text( - context.l10n.diveImport_uddf_duplicate, - style: TextStyle( - fontSize: 12, - color: colorScheme.error, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/universal_import/data/services/import_duplicate_checker.dart b/lib/features/universal_import/data/services/import_duplicate_checker.dart index 00e58f7d1..8795b8a66 100644 --- a/lib/features/universal_import/data/services/import_duplicate_checker.dart +++ b/lib/features/universal_import/data/services/import_duplicate_checker.dart @@ -55,7 +55,7 @@ class ImportDuplicateResult { /// Checks import payload entities against existing data for duplicates. /// -/// Uses the same matching strategies as [UddfDuplicateChecker]: +/// Matching strategies: /// - Name matching (case-insensitive) for simple entities /// - Lat/lon proximity (100m) for sites /// - Name + type compound matching for equipment and certifications diff --git a/test/features/dive_import/data/services/uddf_duplicate_checker_test.dart b/test/features/dive_import/data/services/uddf_duplicate_checker_test.dart deleted file mode 100644 index 326dfa0ec..000000000 --- a/test/features/dive_import/data/services/uddf_duplicate_checker_test.dart +++ /dev/null @@ -1,951 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/core/constants/enums.dart'; -import 'package:submersion/core/services/export/export_service.dart'; -import 'package:submersion/features/buddies/domain/entities/buddy.dart'; -import 'package:submersion/features/certifications/domain/entities/certification.dart'; -import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_duplicate_checker.dart'; -import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart'; -import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; -import 'package:submersion/features/dive_types/domain/entities/dive_type_entity.dart'; -import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; -import 'package:submersion/features/tags/domain/entities/tag.dart'; -import 'package:submersion/features/trips/domain/entities/trip.dart'; - -void main() { - const checker = UddfDuplicateChecker(); - final now = DateTime(2024, 1, 15); - - group('UddfDuplicateCheckResult', () { - test('empty result has no duplicates', () { - const result = UddfDuplicateCheckResult(); - expect(result.hasDuplicates, isFalse); - expect(result.totalDuplicates, 0); - }); - - test('hasDuplicates is true when any set is non-empty', () { - const result = UddfDuplicateCheckResult(duplicateTrips: {0}); - expect(result.hasDuplicates, isTrue); - expect(result.totalDuplicates, 1); - }); - - test('totalDuplicates sums all categories', () { - const result = UddfDuplicateCheckResult( - duplicateTrips: {0}, - duplicateSites: {1, 2}, - duplicateBuddies: {0}, - diveMatches: { - 0: DiveMatchResult( - diveId: 'dive-1', - score: 0.8, - timeDifferenceMs: 100, - ), - }, - ); - expect(result.totalDuplicates, 5); - }); - }); - - group('Name-based duplicates', () { - test('finds trip duplicates by name (case-insensitive)', () { - const importData = UddfImportResult( - trips: [ - {'name': 'Red Sea Trip'}, - {'name': 'Bonaire Adventure'}, - {'name': 'red sea trip'}, // duplicate of index 0 - ], - ); - - final existingTrips = [ - Trip( - id: '1', - name: 'Red Sea Trip', - startDate: now, - endDate: now, - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: existingTrips, - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateTrips, {0, 2}); - }); - - test('finds buddy duplicates by name', () { - const importData = UddfImportResult( - buddies: [ - {'name': 'Alice'}, - {'name': 'Bob'}, - {'name': 'alice'}, // duplicate - ], - ); - - final existingBuddies = [ - Buddy(id: '1', name: 'Alice', createdAt: now, updatedAt: now), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: existingBuddies, - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateBuddies, {0, 2}); - }); - - test('finds dive center duplicates by name', () { - const importData = UddfImportResult( - diveCenters: [ - {'name': 'Blue Dive Shop'}, - {'name': 'Ocean Center'}, - ], - ); - - final existingCenters = [ - DiveCenter( - id: '1', - name: 'blue dive shop', - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: existingCenters, - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateDiveCenters, {0}); - }); - - test('finds tag duplicates by name', () { - const importData = UddfImportResult( - tags: [ - {'name': 'Night Dive'}, - {'name': 'Training'}, - ], - ); - - final existingTags = [ - Tag(id: '1', name: 'Night Dive', createdAt: now, updatedAt: now), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: existingTags, - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateTags, {0}); - }); - - test('skips items with null name', () { - const importData = UddfImportResult( - trips: [ - {'name': null}, - {'name': 'Red Sea Trip'}, - ], - ); - - final existingTrips = [ - Trip( - id: '1', - name: 'Red Sea Trip', - startDate: now, - endDate: now, - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: existingTrips, - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateTrips, {1}); - }); - }); - - group('Site duplicates', () { - test('finds site duplicates by name', () { - const importData = UddfImportResult( - sites: [ - {'name': 'Blue Hole'}, - {'name': 'Shark Point'}, - ], - ); - - final existingSites = [const DiveSite(id: '1', name: 'Blue Hole')]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: existingSites, - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateSites, {0}); - }); - - test('finds site duplicates by lat/lon proximity within 100m', () { - const importData = UddfImportResult( - sites: [ - { - 'name': 'Different Name', - 'latitude': 27.2001, // ~11m from existing - 'longitude': 33.8601, - }, - { - 'name': 'Far Away Site', - 'latitude': 28.0, // far from existing - 'longitude': 34.0, - }, - ], - ); - - final existingSites = [ - const DiveSite( - id: '1', - name: 'Existing Site', - location: GeoPoint(27.2, 33.86), - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: existingSites, - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateSites, {0}); - }); - - test('does not flag site >100m away as duplicate', () { - const importData = UddfImportResult( - sites: [ - { - 'name': 'Different Name', - 'latitude': 27.21, // ~1.1km from existing - 'longitude': 33.86, - }, - ], - ); - - final existingSites = [ - const DiveSite( - id: '1', - name: 'Existing Site', - location: GeoPoint(27.2, 33.86), - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: existingSites, - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateSites, isEmpty); - }); - - test('name match takes priority over lat/lon check', () { - const importData = UddfImportResult( - sites: [ - {'name': 'Blue Hole', 'latitude': 28.0, 'longitude': 34.0}, - ], - ); - - final existingSites = [ - const DiveSite( - id: '1', - name: 'Blue Hole', - location: GeoPoint(27.2, 33.86), // far away coords - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: existingSites, - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - // Matched by name, not coords - expect(result.duplicateSites, {0}); - }); - }); - - group('Equipment duplicates', () { - test('finds equipment duplicates by name + type', () { - const importData = UddfImportResult( - equipment: [ - {'name': 'Apex XTX50', 'type': EquipmentType.regulator}, - {'name': 'Apex XTX50', 'type': EquipmentType.bcd}, // different type - {'name': 'ScubaPro MK25', 'type': EquipmentType.regulator}, - ], - ); - - final existingEquipment = [ - const EquipmentItem( - id: '1', - name: 'Apex XTX50', - type: EquipmentType.regulator, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: existingEquipment, - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateEquipment, {0}); // Only name+type match - }); - - test('handles string equipment type', () { - const importData = UddfImportResult( - equipment: [ - {'name': 'Apex XTX50', 'type': 'regulator'}, - ], - ); - - final existingEquipment = [ - const EquipmentItem( - id: '1', - name: 'Apex XTX50', - type: EquipmentType.regulator, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: existingEquipment, - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateEquipment, {0}); - }); - - test('null type defaults to other', () { - const importData = UddfImportResult( - equipment: [ - {'name': 'Mystery Gear', 'type': null}, - ], - ); - - final existingEquipment = [ - const EquipmentItem( - id: '1', - name: 'Mystery Gear', - type: EquipmentType.other, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: existingEquipment, - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateEquipment, {0}); - }); - }); - - group('Certification duplicates', () { - test('finds certification duplicates by name + agency', () { - const importData = UddfImportResult( - certifications: [ - {'name': 'Open Water', 'agency': CertificationAgency.padi}, - {'name': 'Open Water', 'agency': CertificationAgency.ssi}, - {'name': 'Advanced', 'agency': CertificationAgency.padi}, - ], - ); - - final existingCerts = [ - Certification( - id: '1', - name: 'Open Water', - agency: CertificationAgency.padi, - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: existingCerts, - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - // Only name + same agency matches - expect(result.duplicateCertifications, {0}); - }); - - test('handles string agency', () { - const importData = UddfImportResult( - certifications: [ - {'name': 'Open Water', 'agency': 'padi'}, - ], - ); - - final existingCerts = [ - Certification( - id: '1', - name: 'Open Water', - agency: CertificationAgency.padi, - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: existingCerts, - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateCertifications, {0}); - }); - - test('skips certification with null agency', () { - const importData = UddfImportResult( - certifications: [ - {'name': 'Open Water', 'agency': null}, - ], - ); - - final existingCerts = [ - Certification( - id: '1', - name: 'Open Water', - agency: CertificationAgency.padi, - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: existingCerts, - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.duplicateCertifications, isEmpty); - }); - }); - - group('Dive type duplicates', () { - test('finds dive type duplicates by name', () { - const importData = UddfImportResult( - customDiveTypes: [ - {'name': 'Sidemount', 'id': 'sidemount'}, - {'name': 'Cave', 'id': 'cave'}, - ], - ); - - final existingTypes = [ - DiveTypeEntity( - id: 'sidemount', - name: 'Sidemount', - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: existingTypes, - existingDives: [], - ); - - expect(result.duplicateDiveTypes, {0}); - }); - - test('finds dive type duplicates by slug ID', () { - const importData = UddfImportResult( - customDiveTypes: [ - {'name': 'Side Mount', 'id': 'sidemount'}, - ], - ); - - final existingTypes = [ - DiveTypeEntity( - id: 'sidemount', - name: 'Sidemount Diving', // different name - createdAt: now, - updatedAt: now, - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: existingTypes, - existingDives: [], - ); - - // Matched by ID even though name differs - expect(result.duplicateDiveTypes, {0}); - }); - }); - - group('Dive duplicates (fuzzy matching)', () { - test('finds probable dive match', () { - final diveTime = DateTime(2024, 1, 15, 10, 0); - - final importData = UddfImportResult( - dives: [ - { - 'dateTime': diveTime, - 'maxDepth': 25.0, - 'runtime': const Duration(minutes: 45), - }, - ], - ); - - final existingDives = [ - Dive( - id: 'existing-1', - dateTime: diveTime.add(const Duration(minutes: 1)), - maxDepth: 25.5, - bottomTime: const Duration(minutes: 44), - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: existingDives, - ); - - expect(result.diveMatches, contains(0)); - expect(result.diveMatches[0]!.diveId, 'existing-1'); - expect(result.diveMatches[0]!.score, greaterThanOrEqualTo(0.5)); - }); - - test('no match for very different dives', () { - final importData = UddfImportResult( - dives: [ - { - 'dateTime': DateTime(2024, 1, 15, 10, 0), - 'maxDepth': 25.0, - 'runtime': const Duration(minutes: 45), - }, - ], - ); - - final existingDives = [ - Dive( - id: 'existing-1', - dateTime: DateTime(2024, 6, 1, 14, 0), // very different time - maxDepth: 10.0, - bottomTime: const Duration(minutes: 20), - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: existingDives, - ); - - expect(result.diveMatches, isEmpty); - }); - - test('skips dive with null dateTime', () { - const importData = UddfImportResult( - dives: [ - { - 'dateTime': null, - 'maxDepth': 25.0, - 'runtime': Duration(minutes: 45), - }, - ], - ); - - final existingDives = [ - Dive( - id: 'existing-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - maxDepth: 25.0, - bottomTime: const Duration(minutes: 45), - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: existingDives, - ); - - expect(result.diveMatches, isEmpty); - }); - - test('picks best match when multiple existing dives are similar', () { - final diveTime = DateTime(2024, 1, 15, 10, 0); - - final importData = UddfImportResult( - dives: [ - { - 'dateTime': diveTime, - 'maxDepth': 25.0, - 'runtime': const Duration(minutes: 45), - }, - ], - ); - - final existingDives = [ - Dive( - id: 'close-match', - dateTime: diveTime.add(const Duration(seconds: 30)), - maxDepth: 25.0, - bottomTime: const Duration(minutes: 45), - ), - Dive( - id: 'okay-match', - dateTime: diveTime.add(const Duration(minutes: 5)), - maxDepth: 24.0, - bottomTime: const Duration(minutes: 43), - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: existingDives, - ); - - expect(result.diveMatches[0]!.diveId, 'close-match'); - }); - - test('uses duration field as fallback for runtime', () { - final diveTime = DateTime(2024, 1, 15, 10, 0); - - final importData = UddfImportResult( - dives: [ - { - 'dateTime': diveTime, - 'maxDepth': 25.0, - 'duration': const Duration(minutes: 45), - }, - ], - ); - - final existingDives = [ - Dive( - id: 'existing-1', - dateTime: diveTime, - maxDepth: 25.0, - bottomTime: const Duration(minutes: 45), - ), - ]; - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: existingDives, - ); - - expect(result.diveMatches, contains(0)); - }); - - test('returns empty map when no existing dives', () { - final importData = UddfImportResult( - dives: [ - { - 'dateTime': DateTime(2024, 1, 15, 10, 0), - 'maxDepth': 25.0, - 'runtime': const Duration(minutes: 45), - }, - ], - ); - - final result = checker.check( - importData: importData, - existingTrips: [], - existingSites: [], - existingEquipment: [], - existingBuddies: [], - existingDiveCenters: [], - existingCertifications: [], - existingTags: [], - existingDiveTypes: [], - existingDives: [], - ); - - expect(result.diveMatches, isEmpty); - }); - }); - - group('Full check with multiple entity types', () { - test('detects duplicates across all entity types', () { - final diveTime = DateTime(2024, 1, 15, 10, 0); - - final importData = UddfImportResult( - trips: [ - {'name': 'Egypt Trip'}, - ], - sites: [ - {'name': 'Blue Hole'}, - ], - equipment: [ - {'name': 'My Reg', 'type': EquipmentType.regulator}, - ], - buddies: [ - {'name': 'Alice'}, - ], - diveCenters: [ - {'name': 'Blue Dive'}, - ], - certifications: [ - {'name': 'OW', 'agency': CertificationAgency.padi}, - ], - tags: [ - {'name': 'Night'}, - ], - customDiveTypes: [ - {'name': 'Cave', 'id': 'cave'}, - ], - dives: [ - { - 'dateTime': diveTime, - 'maxDepth': 25.0, - 'runtime': const Duration(minutes: 45), - }, - ], - ); - - final result = checker.check( - importData: importData, - existingTrips: [ - Trip( - id: '1', - name: 'Egypt Trip', - startDate: now, - endDate: now, - createdAt: now, - updatedAt: now, - ), - ], - existingSites: [const DiveSite(id: '1', name: 'Blue Hole')], - existingEquipment: [ - const EquipmentItem( - id: '1', - name: 'My Reg', - type: EquipmentType.regulator, - ), - ], - existingBuddies: [ - Buddy(id: '1', name: 'Alice', createdAt: now, updatedAt: now), - ], - existingDiveCenters: [ - DiveCenter( - id: '1', - name: 'Blue Dive', - createdAt: now, - updatedAt: now, - ), - ], - existingCertifications: [ - Certification( - id: '1', - name: 'OW', - agency: CertificationAgency.padi, - createdAt: now, - updatedAt: now, - ), - ], - existingTags: [ - Tag(id: '1', name: 'Night', createdAt: now, updatedAt: now), - ], - existingDiveTypes: [ - DiveTypeEntity( - id: 'cave', - name: 'Cave', - createdAt: now, - updatedAt: now, - ), - ], - existingDives: [ - Dive( - id: 'dive-1', - dateTime: diveTime, - maxDepth: 25.0, - bottomTime: const Duration(minutes: 45), - ), - ], - ); - - expect(result.hasDuplicates, isTrue); - expect(result.duplicateTrips, {0}); - expect(result.duplicateSites, {0}); - expect(result.duplicateEquipment, {0}); - expect(result.duplicateBuddies, {0}); - expect(result.duplicateDiveCenters, {0}); - expect(result.duplicateCertifications, {0}); - expect(result.duplicateTags, {0}); - expect(result.duplicateDiveTypes, {0}); - expect(result.diveMatches, contains(0)); - }); - }); -} diff --git a/test/features/dive_import/data/services/uddf_parser_service_test.dart b/test/features/dive_import/data/services/uddf_parser_service_test.dart deleted file mode 100644 index a9956cd2f..000000000 --- a/test/features/dive_import/data/services/uddf_parser_service_test.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:submersion/core/services/export/export_service.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_parser_service.dart'; - -@GenerateMocks([ExportService]) -import 'uddf_parser_service_test.mocks.dart'; - -void main() { - late MockExportService mockExportService; - late UddfParserService parserService; - - setUp(() { - mockExportService = MockExportService(); - parserService = UddfParserService(mockExportService); - }); - - group('UddfParserService', () { - group('validExtensions', () { - test('accepts uddf extension', () { - expect(UddfParserService.validExtensions.contains('uddf'), isTrue); - }); - - test('accepts xml extension', () { - expect(UddfParserService.validExtensions.contains('xml'), isTrue); - }); - }); - - group('parseFile', () { - test('rejects invalid file extension', () async { - expect( - () => parserService.parseFile('/path/to/file.txt'), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Please select a UDDF or XML file', - ), - ), - ); - }); - - test('rejects json extension', () async { - expect( - () => parserService.parseFile('/path/to/file.json'), - throwsA(isA()), - ); - }); - - test('rejects file with no extension', () async { - // 'myfile' split by '.' gives ['myfile'], last is 'myfile' - expect( - () => parserService.parseFile('/path/to/myfile'), - throwsA(isA()), - ); - }); - - test('throws when file does not exist', () async { - expect( - () => parserService.parseFile('/nonexistent/path/to/file.uddf'), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'File not found', - ), - ), - ); - }); - - test('parses valid uddf file', () async { - // Create a temp file with UDDF content - final tempDir = await Directory.systemTemp.createTemp('uddf_test_'); - final tempFile = File('${tempDir.path}/test.uddf'); - await tempFile.writeAsString(''); - - when( - mockExportService.importAllDataFromUddf(''), - ).thenAnswer((_) async => const UddfImportResult()); - - final result = await parserService.parseFile(tempFile.path); - expect(result, isNotNull); - expect(result.dives, isEmpty); - - // Cleanup - await tempDir.delete(recursive: true); - }); - - test('accepts xml extension', () async { - final tempDir = await Directory.systemTemp.createTemp('uddf_test_'); - final tempFile = File('${tempDir.path}/test.xml'); - await tempFile.writeAsString(''); - - when( - mockExportService.importAllDataFromUddf(''), - ).thenAnswer((_) async => const UddfImportResult()); - - final result = await parserService.parseFile(tempFile.path); - expect(result, isNotNull); - - await tempDir.delete(recursive: true); - }); - - test('extension check is case-insensitive', () async { - final tempDir = await Directory.systemTemp.createTemp('uddf_test_'); - final tempFile = File('${tempDir.path}/test.UDDF'); - await tempFile.writeAsString(''); - - when( - mockExportService.importAllDataFromUddf(''), - ).thenAnswer((_) async => const UddfImportResult()); - - final result = await parserService.parseFile(tempFile.path); - expect(result, isNotNull); - - await tempDir.delete(recursive: true); - }); - }); - - group('parseContent', () { - test('delegates to ExportService', () async { - const content = 'test'; - final expectedResult = UddfImportResult( - dives: [ - {'dateTime': DateTime(2024), 'maxDepth': 25.0}, - ], - ); - - when( - mockExportService.importAllDataFromUddf(content), - ).thenAnswer((_) async => expectedResult); - - final result = await parserService.parseContent(content); - expect(result.dives, hasLength(1)); - verify(mockExportService.importAllDataFromUddf(content)).called(1); - }); - }); - }); - - group('UddfParseException', () { - test('toString includes message', () { - const exception = UddfParseException('test error'); - expect(exception.toString(), 'UddfParseException: test error'); - }); - - test('message is accessible', () { - const exception = UddfParseException('something broke'); - expect(exception.message, 'something broke'); - }); - }); -} diff --git a/test/features/dive_import/presentation/providers/uddf_import_providers_test.dart b/test/features/dive_import/presentation/providers/uddf_import_providers_test.dart deleted file mode 100644 index 6caebf726..000000000 --- a/test/features/dive_import/presentation/providers/uddf_import_providers_test.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/core/services/export/export_service.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_duplicate_checker.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; -import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; -import 'package:submersion/features/dive_import/presentation/providers/uddf_import_providers.dart'; - -void main() { - group('UddfImportState', () { - test('default state has step 0 and empty selections', () { - const state = UddfImportState(); - expect(state.currentStep, 0); - expect(state.isLoading, isFalse); - expect(state.isImporting, isFalse); - expect(state.error, isNull); - expect(state.parsedData, isNull); - expect(state.duplicateCheckResult, isNull); - expect(state.totalSelected, 0); - expect(state.importResult, isNull); - }); - - test('copyWith updates fields', () { - const state = UddfImportState(); - final updated = state.copyWith( - currentStep: 1, - isLoading: true, - error: 'something failed', - ); - expect(updated.currentStep, 1); - expect(updated.isLoading, isTrue); - expect(updated.error, 'something failed'); - }); - - test('copyWith preserves unchanged fields', () { - final state = const UddfImportState().copyWith( - selectedTrips: {0, 1}, - selectedDives: {0}, - ); - final updated = state.copyWith(currentStep: 2); - expect(updated.selectedTrips, {0, 1}); - expect(updated.selectedDives, {0}); - }); - - test('clearError resets error to null', () { - final state = const UddfImportState().copyWith(error: 'old error'); - final updated = state.copyWith(clearError: true); - expect(updated.error, isNull); - }); - - test('clearError takes priority over error param', () { - final state = const UddfImportState().copyWith(error: 'old error'); - final updated = state.copyWith(clearError: true, error: 'new error'); - expect(updated.error, isNull); - }); - - test('totalSelected sums all selection sets', () { - final state = const UddfImportState().copyWith( - selectedTrips: {0, 1}, - selectedSites: {0, 1, 2}, - selectedDives: {0}, - selectedBuddies: {0}, - ); - expect(state.totalSelected, 7); - }); - - test('selectionFor returns correct set', () { - final state = const UddfImportState().copyWith( - selectedTrips: {0}, - selectedDives: {1, 2}, - ); - expect(state.selectionFor(UddfEntityType.trips), {0}); - expect(state.selectionFor(UddfEntityType.dives), {1, 2}); - expect(state.selectionFor(UddfEntityType.buddies), isEmpty); - }); - - test('totalCountFor returns data length', () { - final data = UddfImportResult( - trips: [ - {'name': 'A'}, - {'name': 'B'}, - ], - dives: [ - {'dateTime': DateTime(2024)}, - ], - ); - final state = const UddfImportState().copyWith(parsedData: data); - expect(state.totalCountFor(UddfEntityType.trips), 2); - expect(state.totalCountFor(UddfEntityType.dives), 1); - expect(state.totalCountFor(UddfEntityType.buddies), 0); - }); - - test('totalCountFor returns 0 when no parsed data', () { - const state = UddfImportState(); - expect(state.totalCountFor(UddfEntityType.trips), 0); - }); - - test('toSelections creates matching UddfImportSelections', () { - final state = const UddfImportState().copyWith( - selectedTrips: {0}, - selectedEquipment: {1, 2}, - selectedBuddies: {0}, - selectedDives: {0, 1, 2}, - ); - final selections = state.toSelections(); - expect(selections.trips, {0}); - expect(selections.equipment, {1, 2}); - expect(selections.buddies, {0}); - expect(selections.dives, {0, 1, 2}); - expect(selections.sites, isEmpty); - }); - }); - - group('UddfEntityType', () { - test('has all expected values', () { - expect(UddfEntityType.values, hasLength(11)); - expect(UddfEntityType.values, contains(UddfEntityType.trips)); - expect(UddfEntityType.values, contains(UddfEntityType.dives)); - expect(UddfEntityType.values, contains(UddfEntityType.sites)); - expect(UddfEntityType.values, contains(UddfEntityType.equipment)); - expect(UddfEntityType.values, contains(UddfEntityType.buddies)); - expect(UddfEntityType.values, contains(UddfEntityType.diveCenters)); - expect(UddfEntityType.values, contains(UddfEntityType.certifications)); - expect(UddfEntityType.values, contains(UddfEntityType.tags)); - expect(UddfEntityType.values, contains(UddfEntityType.diveTypes)); - expect(UddfEntityType.values, contains(UddfEntityType.equipmentSets)); - expect(UddfEntityType.values, contains(UddfEntityType.courses)); - }); - }); - - group('UddfImportNotifier selection logic', () { - // Test selection management using the state directly - // (Notifier methods that use Riverpod refs need integration tests) - - test('toggle adds index when not present', () { - final state = const UddfImportState().copyWith(selectedTrips: {0, 1}); - - // Simulate toggleSelection logic - final current = state.selectionFor(UddfEntityType.trips); - final updated = Set.from(current); - updated.add(2); - - final newState = state.copyWith(selectedTrips: updated); - expect(newState.selectedTrips, {0, 1, 2}); - }); - - test('toggle removes index when present', () { - final state = const UddfImportState().copyWith(selectedTrips: {0, 1, 2}); - - final current = state.selectionFor(UddfEntityType.trips); - final updated = Set.from(current); - updated.remove(1); - - final newState = state.copyWith(selectedTrips: updated); - expect(newState.selectedTrips, {0, 2}); - }); - - test('selectAll generates all indices', () { - const data = UddfImportResult( - trips: [ - {'name': 'A'}, - {'name': 'B'}, - {'name': 'C'}, - ], - ); - final state = const UddfImportState().copyWith(parsedData: data); - - final count = state.totalCountFor(UddfEntityType.trips); - final allIndices = Set.from(List.generate(count, (i) => i)); - - final newState = state.copyWith(selectedTrips: allIndices); - expect(newState.selectedTrips, {0, 1, 2}); - }); - - test('deselectAll clears selection', () { - final state = const UddfImportState().copyWith(selectedTrips: {0, 1, 2}); - - final newState = state.copyWith(selectedTrips: const {}); - expect(newState.selectedTrips, isEmpty); - }); - }); - - group('Duplicate auto-deselection', () { - test('duplicates are removed from default selections', () { - // Simulate what parseFile does: selectAll minus duplicates - final data = UddfImportResult( - trips: [ - {'name': 'Trip A'}, - {'name': 'Trip B'}, - {'name': 'Trip C'}, - ], - buddies: [ - {'name': 'Alice'}, - {'name': 'Bob'}, - ], - dives: [ - {'dateTime': DateTime(2024, 1, 1), 'maxDepth': 20.0}, - {'dateTime': DateTime(2024, 1, 2), 'maxDepth': 25.0}, - ], - ); - - const dupResult = UddfDuplicateCheckResult( - duplicateTrips: {0, 2}, - duplicateBuddies: {1}, - diveMatches: { - 0: DiveMatchResult( - diveId: 'existing-1', - score: 0.8, - timeDifferenceMs: 100, - ), - }, - ); - - final selections = UddfImportSelections.selectAll(data); - - final state = const UddfImportState().copyWith( - parsedData: data, - duplicateCheckResult: dupResult, - selectedTrips: selections.trips.difference(dupResult.duplicateTrips), - selectedBuddies: selections.buddies.difference( - dupResult.duplicateBuddies, - ), - selectedDives: selections.dives.difference( - Set.from(dupResult.diveMatches.keys), - ), - ); - - // Trip A (0) and Trip C (2) are duplicates, only Trip B (1) selected - expect(state.selectedTrips, {1}); - - // Bob (1) is a duplicate, only Alice (0) selected - expect(state.selectedBuddies, {0}); - - // Dive 0 matches an existing dive, only dive 1 selected - expect(state.selectedDives, {1}); - }); - }); - - group('UddfEntityImportResult in state', () { - test('import result is accessible after import', () { - const result = UddfEntityImportResult(dives: 5, sites: 2, trips: 1); - - final state = const UddfImportState().copyWith( - currentStep: 3, - importResult: result, - ); - - expect(state.importResult, isNotNull); - expect(state.importResult!.total, 8); - expect(state.importResult!.summary, contains('5 dives')); - expect(state.importResult!.summary, contains('2 sites')); - expect(state.importResult!.summary, contains('1 trips')); - }); - }); - - group('Progress tracking in state', () { - test('progress fields update correctly', () { - final state = const UddfImportState().copyWith( - importPhase: 'Importing dives', - importCurrent: 3, - importTotal: 10, - ); - - expect(state.importPhase, 'Importing dives'); - expect(state.importCurrent, 3); - expect(state.importTotal, 10); - }); - }); -} diff --git a/test/features/dive_import/presentation/widgets/uddf_entity_card_test.dart b/test/features/dive_import/presentation/widgets/uddf_entity_card_test.dart deleted file mode 100644 index bb9a490f7..000000000 --- a/test/features/dive_import/presentation/widgets/uddf_entity_card_test.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/features/dive_import/presentation/widgets/uddf_entity_card.dart'; -import 'package:submersion/l10n/arb/app_localizations.dart'; - -void main() { - Widget buildTestWidget({ - String name = 'Test Item', - String? subtitle, - IconData icon = Icons.label_outline, - bool isSelected = false, - VoidCallback? onToggle, - bool isDuplicate = false, - }) { - return MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: ListView( - children: [ - UddfEntityCard( - name: name, - subtitle: subtitle, - icon: icon, - isSelected: isSelected, - onToggle: onToggle ?? () {}, - isDuplicate: isDuplicate, - ), - ], - ), - ), - ); - } - - testWidgets('displays name', (tester) async { - await tester.pumpWidget(buildTestWidget(name: 'My Trip')); - expect(find.text('My Trip'), findsOneWidget); - }); - - testWidgets('displays subtitle when provided', (tester) async { - await tester.pumpWidget( - buildTestWidget(name: 'Equipment', subtitle: 'BCD'), - ); - expect(find.text('BCD'), findsOneWidget); - }); - - testWidgets('hides subtitle when null', (tester) async { - await tester.pumpWidget(buildTestWidget(name: 'Tag')); - expect(find.text('Tag'), findsOneWidget); - // Only 1 text widget for the name, no subtitle - final textWidgets = tester.widgetList(find.byType(Text)); - expect(textWidgets.length, 1); - }); - - testWidgets('displays entity icon', (tester) async { - await tester.pumpWidget(buildTestWidget(icon: Icons.person_outline)); - expect(find.byIcon(Icons.person_outline), findsOneWidget); - }); - - testWidgets('shows check icon when selected', (tester) async { - await tester.pumpWidget(buildTestWidget(isSelected: true)); - expect(find.byIcon(Icons.check), findsOneWidget); - }); - - testWidgets('hides check icon when not selected', (tester) async { - await tester.pumpWidget(buildTestWidget(isSelected: false)); - expect(find.byIcon(Icons.check), findsNothing); - }); - - testWidgets('shows duplicate badge when isDuplicate is true', (tester) async { - await tester.pumpWidget(buildTestWidget(isDuplicate: true)); - expect(find.text('Duplicate'), findsOneWidget); - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - }); - - testWidgets('hides duplicate badge when isDuplicate is false', ( - tester, - ) async { - await tester.pumpWidget(buildTestWidget(isDuplicate: false)); - expect(find.text('Duplicate'), findsNothing); - }); - - testWidgets('calls onToggle when tapped', (tester) async { - var toggleCount = 0; - await tester.pumpWidget(buildTestWidget(onToggle: () => toggleCount++)); - await tester.tap(find.byType(UddfEntityCard)); - expect(toggleCount, 1); - }); - - testWidgets('card has elevated style when selected', (tester) async { - await tester.pumpWidget(buildTestWidget(isSelected: true)); - final card = tester.widget(find.byType(Card)); - expect(card.elevation, 2); - }); - - testWidgets('card has flat style when not selected', (tester) async { - await tester.pumpWidget(buildTestWidget(isSelected: false)); - final card = tester.widget(find.byType(Card)); - expect(card.elevation, 0); - }); -} From eeb783c65f532f3ab6bbb73216717354de4b36e4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 6 Jul 2026 10:59:54 -0400 Subject: [PATCH 3/4] refactor(import): remove orphaned file-import consolidation UI and state --- .../data/models/import_enums.dart | 20 - .../providers/universal_import_providers.dart | 253 ------------- .../providers/universal_import_state.dart | 9 - .../widgets/import_dive_card.dart | 247 ------------ .../widgets/import_review_step.dart | 268 ------------- .../data/models/import_enums_test.dart | 21 -- .../universal_import_notifier_test.dart | 356 +----------------- 7 files changed, 1 insertion(+), 1173 deletions(-) delete mode 100644 lib/features/universal_import/presentation/widgets/import_dive_card.dart delete mode 100644 lib/features/universal_import/presentation/widgets/import_review_step.dart diff --git a/lib/features/universal_import/data/models/import_enums.dart b/lib/features/universal_import/data/models/import_enums.dart index a722c96e3..09b9325b1 100644 --- a/lib/features/universal_import/data/models/import_enums.dart +++ b/lib/features/universal_import/data/models/import_enums.dart @@ -225,26 +225,6 @@ class SourceOverrideOption { int get hashCode => Object.hash(sourceApp, format); } -/// Resolution choice for a dive that was flagged as a potential duplicate. -/// -/// When a dive match is detected during import, the user can choose how to -/// handle it: -/// - [skip]: Do not import this dive at all (default for matched dives). -/// - [importAsNew]: Import the dive as a brand-new separate entry. -/// - [consolidate]: Attach the imported data as a secondary computer reading -/// on the matched existing dive. -enum DiveDuplicateResolution { - skip, - importAsNew, - consolidate; - - String get displayName => switch (this) { - skip => 'Skip', - importAsNew => 'Import as New', - consolidate => 'Consolidate as additional computer', - }; -} - /// Entity types that can be included in an import payload. /// /// Mirrors the existing `UddfEntityType` but used across all import formats. diff --git a/lib/features/universal_import/presentation/providers/universal_import_providers.dart b/lib/features/universal_import/presentation/providers/universal_import_providers.dart index 5ca14db3e..9ac3aece9 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -3,29 +3,20 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/core/services/export/models/uddf_import_result.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; -import 'package:submersion/features/courses/presentation/providers/course_providers.dart'; import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; -import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; import 'package:submersion/features/dive_types/presentation/providers/dive_type_providers.dart'; -import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/equipment/presentation/providers/equipment_providers.dart'; -import 'package:submersion/features/equipment/presentation/providers/equipment_set_providers.dart'; -import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; -import 'package:submersion/features/tank_presets/domain/services/default_tank_preset_resolver.dart'; -import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; import 'package:submersion/features/universal_import/data/models/detection_result.dart'; import 'package:submersion/features/universal_import/data/models/field_mapping.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; -import 'package:submersion/features/universal_import/data/models/import_warning.dart'; import 'package:submersion/features/universal_import/data/csv/pipeline/csv_pipeline.dart'; import 'package:submersion/features/universal_import/data/csv/presets/built_in_presets.dart'; import 'package:submersion/features/universal_import/data/csv/presets/preset_registry.dart'; @@ -43,7 +34,6 @@ import 'package:submersion/features/universal_import/data/services/format_detect import 'package:submersion/features/universal_import/data/services/macdive_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; -import 'package:submersion/features/universal_import/presentation/providers/import_consolidation_service.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_state.dart'; export 'package:submersion/features/universal_import/presentation/providers/universal_import_state.dart'; @@ -511,249 +501,6 @@ class UniversalImportNotifier extends StateNotifier { ); } - /// Set the duplicate resolution for a specific dive index. - /// - /// When [resolution] is [DiveDuplicateResolution.consolidate] or - /// [DiveDuplicateResolution.importAsNew], the dive index is added to the - /// selection so it participates in the import step. When [resolution] is - /// [DiveDuplicateResolution.skip], it is removed from the selection. - void setDiveResolution(int index, DiveDuplicateResolution resolution) { - final updatedResolutions = Map.from( - state.diveResolutions, - )..[index] = resolution; - - final currentSelection = Set.from( - state.selectionFor(ImportEntityType.dives), - ); - if (resolution == DiveDuplicateResolution.skip) { - currentSelection.remove(index); - } else { - currentSelection.add(index); - } - - state = state.copyWith( - diveResolutions: updatedResolutions, - selections: { - ...state.selections, - ImportEntityType.dives: currentSelection, - }, - ); - } - - // -- Step 4: Import -- - - /// Perform the import with current selections. - Future performImport() async { - final payload = state.payload; - if (payload == null) return; - - state = state.copyWith( - currentStep: ImportWizardStep.importing, - isImporting: true, - clearError: true, - ); - - try { - final currentDiver = await _ref.read(currentDiverProvider.future); - if (currentDiver == null) { - state = state.copyWith( - isImporting: false, - error: 'Please create a diver profile before importing', - ); - return; - } - - // Partition dive selections: consolidate vs normal import. Both are - // imported as full standalone dives in the SAME importer.import() call - // below (so consolidate-flagged dives get every sample column, tank, - // pressure, and event, plus correct cross-references to trips/sites/ - // buddies from this payload) -- consolidate-flagged indices are then - // folded into their matched dive afterwards via performConsolidations. - final consolidateIndices = {}; - final normalDiveSelection = Set.from( - state.selectionFor(ImportEntityType.dives), - ); - for (final entry in state.diveResolutions.entries) { - if (entry.value == DiveDuplicateResolution.consolidate) { - consolidateIndices.add(entry.key); - normalDiveSelection.remove(entry.key); - } - } - - // Convert to UDDF format for reuse of UddfEntityImporter - final uddfData = _toUddfResult(payload); - final uddfSelections = _toUddfSelections({ - ...state.selections, - ImportEntityType.dives: {...normalDiveSelection, ...consolidateIndices}, - }); - - final repos = ImportRepositories( - tripRepository: _ref.read(tripRepositoryProvider), - equipmentRepository: _ref.read(equipmentRepositoryProvider), - equipmentSetRepository: _ref.read(equipmentSetRepositoryProvider), - buddyRepository: _ref.read(buddyRepositoryProvider), - diveCenterRepository: _ref.read(diveCenterRepositoryProvider), - certificationRepository: _ref.read(certificationRepositoryProvider), - tagRepository: _ref.read(tagRepositoryProvider), - diveTypeRepository: _ref.read(diveTypeRepositoryProvider), - siteRepository: _ref.read(siteRepositoryProvider), - diveRepository: _ref.read(diveRepositoryProvider), - tankPressureRepository: _ref.read(tankPressureRepositoryProvider), - courseRepository: _ref.read(courseRepositoryProvider), - ); - - final settings = _ref.read(settingsProvider); - final resolver = DefaultTankPresetResolver( - repository: _ref.read(tankPresetRepositoryProvider), - ); - final defaultTankPreset = await resolver.resolve( - settings.defaultTankPreset, - ); - final importer = UddfEntityImporter( - defaultTankPreset: defaultTankPreset, - defaultStartPressure: settings.defaultStartPressure, - applyDefaultTankToImports: settings.applyDefaultTankToImports, - ); - final result = await importer.import( - data: uddfData, - selections: uddfSelections, - repositories: repos, - diverId: currentDiver.id, - onProgress: (phase, current, total) { - state = state.copyWith( - importPhase: phase.name, - importCurrent: current, - importTotal: total, - ); - }, - ); - - // Fold consolidate-flagged dives (just imported above as standalone - // dives) into their matched existing dive. - var consolidationWarnings = const []; - if (consolidateIndices.isNotEmpty) { - final consolidationService = _ref.read( - diveConsolidationServiceProvider, - ); - final summary = await performConsolidations( - indices: consolidateIndices, - diveIdByIndex: result.diveIdByIndex, - duplicateResult: state.duplicateResult, - consolidationService: consolidationService, - diveRepository: repos.diveRepository, - ); - if (summary.failed > 0) { - consolidationWarnings = [ - ImportWarning( - severity: ImportWarningSeverity.error, - message: - '${summary.failed} dive(s) could not be consolidated ' - 'into their matched dive; the partial imports were ' - 'removed again to avoid duplicates.', - entityType: ImportEntityType.dives, - ), - ]; - } - } - - _invalidateProviders(); - - final counts = { - if (result.dives > 0) ImportEntityType.dives: result.dives, - if (result.sites > 0) ImportEntityType.sites: result.sites, - if (result.trips > 0) ImportEntityType.trips: result.trips, - if (result.equipment > 0) ImportEntityType.equipment: result.equipment, - if (result.equipmentSets > 0) - ImportEntityType.equipmentSets: result.equipmentSets, - if (result.buddies > 0) ImportEntityType.buddies: result.buddies, - if (result.diveCenters > 0) - ImportEntityType.diveCenters: result.diveCenters, - if (result.certifications > 0) - ImportEntityType.certifications: result.certifications, - if (result.courses > 0) ImportEntityType.courses: result.courses, - if (result.tags > 0) ImportEntityType.tags: result.tags, - if (result.diveTypes > 0) ImportEntityType.diveTypes: result.diveTypes, - }; - - state = state.copyWith( - currentStep: ImportWizardStep.summary, - isImporting: false, - importCounts: counts, - payload: consolidationWarnings.isEmpty - ? null - : ImportPayload( - entities: payload.entities, - warnings: [...payload.warnings, ...consolidationWarnings], - metadata: payload.metadata, - ), - ); - } catch (e) { - state = state.copyWith(isImporting: false, error: 'Import failed: $e'); - } - } - - // -- Adapter Helpers -- - - static UddfImportResult _toUddfResult(ImportPayload payload) { - return UddfImportResult( - dives: payload.entitiesOf(ImportEntityType.dives), - sites: payload.entitiesOf(ImportEntityType.sites), - trips: payload.entitiesOf(ImportEntityType.trips), - equipment: payload.entitiesOf(ImportEntityType.equipment), - buddies: payload.entitiesOf(ImportEntityType.buddies), - diveCenters: payload.entitiesOf(ImportEntityType.diveCenters), - certifications: payload.entitiesOf(ImportEntityType.certifications), - tags: payload.entitiesOf(ImportEntityType.tags), - customDiveTypes: payload.entitiesOf(ImportEntityType.diveTypes), - equipmentSets: payload.entitiesOf(ImportEntityType.equipmentSets), - courses: payload.entitiesOf(ImportEntityType.courses), - ); - } - - static UddfImportSelections _toUddfSelections( - Map> sel, - ) { - return UddfImportSelections( - trips: sel[ImportEntityType.trips] ?? const {}, - equipment: sel[ImportEntityType.equipment] ?? const {}, - buddies: sel[ImportEntityType.buddies] ?? const {}, - diveCenters: sel[ImportEntityType.diveCenters] ?? const {}, - certifications: sel[ImportEntityType.certifications] ?? const {}, - tags: sel[ImportEntityType.tags] ?? const {}, - diveTypes: sel[ImportEntityType.diveTypes] ?? const {}, - sites: sel[ImportEntityType.sites] ?? const {}, - equipmentSets: sel[ImportEntityType.equipmentSets] ?? const {}, - dives: sel[ImportEntityType.dives] ?? const {}, - courses: sel[ImportEntityType.courses] ?? const {}, - ); - } - - // -- Provider Invalidation -- - - void _invalidateProviders() { - _ref.invalidate(sitesProvider); - _ref.invalidate(sitesWithCountsProvider); - _ref.invalidate(siteListNotifierProvider); - _ref.invalidate(allBuddiesProvider); - _ref.invalidate(buddyListNotifierProvider); - _ref.invalidate(allEquipmentProvider); - _ref.invalidate(activeEquipmentProvider); - _ref.invalidate(retiredEquipmentProvider); - _ref.invalidate(serviceDueEquipmentProvider); - _ref.invalidate(equipmentListNotifierProvider); - _ref.invalidate(equipmentSetsProvider); - _ref.invalidate(allTripsProvider); - _ref.invalidate(allDiveCentersProvider); - _ref.invalidate(allCertificationsProvider); - _ref.invalidate(diveTypesProvider); - _ref.invalidate(tagsProvider); - _ref.invalidate(diveListNotifierProvider); - _ref.invalidate(paginatedDiveListProvider); - _ref.invalidate(diveStatisticsProvider); - _ref.invalidate(diveRecordsProvider); - _ref.invalidate(allBuddiesWithDiveCountProvider); - } - /// Clear the external-load flag after the wizard has consumed it. void clearExternalLoadFlag() { state = state.copyWith(wasLoadedExternally: false); diff --git a/lib/features/universal_import/presentation/providers/universal_import_state.dart b/lib/features/universal_import/presentation/providers/universal_import_state.dart index 3252aed78..b85311364 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_state.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_state.dart @@ -49,7 +49,6 @@ class UniversalImportState { this.payload, this.duplicateResult, this.selections = const {}, - this.diveResolutions = const {}, this.importCounts = const {}, this.importPhase = '', this.importCurrent = 0, @@ -103,12 +102,6 @@ class UniversalImportState { /// Selected indices per entity type. final Map> selections; - /// Per-dive duplicate resolution choices (keyed by dive index). - /// - /// Only present for dives that were flagged as potential duplicates. - /// Absent entries default to [DiveDuplicateResolution.skip]. - final Map diveResolutions; - /// Import result counts per entity type. final Map importCounts; @@ -142,7 +135,6 @@ class UniversalImportState { bool clearPayload = false, ImportDuplicateResult? duplicateResult, Map>? selections, - Map? diveResolutions, Map? importCounts, String? importPhase, int? importCurrent, @@ -188,7 +180,6 @@ class UniversalImportState { payload: clearPayload ? null : (payload ?? this.payload), duplicateResult: duplicateResult ?? this.duplicateResult, selections: selections ?? this.selections, - diveResolutions: diveResolutions ?? this.diveResolutions, importCounts: importCounts ?? this.importCounts, importPhase: importPhase ?? this.importPhase, importCurrent: importCurrent ?? this.importCurrent, diff --git a/lib/features/universal_import/presentation/widgets/import_dive_card.dart b/lib/features/universal_import/presentation/widgets/import_dive_card.dart deleted file mode 100644 index 58735b08a..000000000 --- a/lib/features/universal_import/presentation/widgets/import_dive_card.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import 'package:submersion/core/domain/models/incoming_dive_data.dart'; -import 'package:submersion/core/presentation/widgets/dive_comparison_card.dart'; -import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; -import 'package:submersion/features/universal_import/data/models/import_enums.dart'; -import 'package:submersion/features/universal_import/presentation/widgets/duplicate_badge.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; - -/// Card displaying a dive entry for selection in the import wizard. -/// -/// Shows dive date, depth, duration, and site name with a selection -/// checkbox and optional duplicate match badge with confidence score. -/// When a probable duplicate is detected, shows an expandable -/// [DiveComparisonCard] with profile overlay, field comparison, and -/// resolution action buttons. -class ImportDiveCard extends StatefulWidget { - const ImportDiveCard({ - super.key, - required this.diveData, - required this.index, - required this.isSelected, - required this.onToggle, - this.matchResult, - this.resolution, - this.onResolutionChanged, - }); - - final Map diveData; - final int index; - final bool isSelected; - final VoidCallback onToggle; - final DiveMatchResult? matchResult; - final DiveDuplicateResolution? resolution; - final ValueChanged? onResolutionChanged; - - @override - State createState() => _ImportDiveCardState(); -} - -class _ImportDiveCardState extends State { - bool _isExpanded = false; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - final dateTime = widget.diveData['dateTime'] as DateTime?; - final maxDepth = widget.diveData['maxDepth'] as double?; - final duration = widget.diveData['duration'] as Duration?; - final runtime = widget.diveData['runtime'] as Duration?; - final siteName = widget.diveData['siteName'] as String?; - final diveNumber = widget.diveData['diveNumber'] as int?; - - final dateStr = dateTime != null - ? DateFormat('MMM d, y - HH:mm').format(dateTime) - : context.l10n.universalImport_label_unknownDate; - - final depthStr = maxDepth != null ? '${maxDepth.toStringAsFixed(1)}m' : ''; - final durationMin = (runtime ?? duration)?.inMinutes; - final durationStr = durationMin != null ? '${durationMin}min' : ''; - - final title = diveNumber != null - ? context.l10n.universalImport_label_diveNumber(diveNumber) - : dateStr; - final subtitle = diveNumber != null - ? dateStr - : [ - depthStr, - durationStr, - siteName ?? '', - ].where((s) => s.isNotEmpty).join(' / '); - - final hasMatch = - widget.matchResult != null && widget.matchResult!.score >= 0.5; - - return Card( - elevation: widget.isSelected ? 2 : 0, - color: widget.isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - child: Column( - children: [ - Semantics( - button: true, - label: context.l10n.universalImport_semantics_toggleSelection( - title, - ), - child: InkWell( - onTap: widget.onToggle, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _buildCheckbox(colorScheme), - const SizedBox(width: 12), - Icon( - Icons.scuba_diving, - size: 20, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (diveNumber != null) ...[ - const SizedBox(height: 2), - _buildMetrics( - theme, - depthStr, - durationStr, - siteName, - ), - ], - ], - ), - ), - if (widget.matchResult != null) ...[ - const SizedBox(width: 8), - DuplicateBadge( - isProbable: widget.matchResult!.score >= 0.7, - label: context.l10n - .universalImport_label_percentMatch( - (widget.matchResult!.score * 100) - .toStringAsFixed(0), - ), - ), - ], - ], - ), - // Expand/collapse toggle for match comparison - if (hasMatch) - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () => - setState(() => _isExpanded = !_isExpanded), - icon: Icon( - _isExpanded ? Icons.expand_less : Icons.expand_more, - size: 16, - ), - label: Text( - _isExpanded ? 'Hide comparison' : 'Compare dives', - style: theme.textTheme.labelSmall, - ), - ), - ), - ], - ), - ), - ), - ), - // Expandable comparison card - if (hasMatch && _isExpanded) - Padding( - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: DiveComparisonCard( - embedded: true, - incoming: IncomingDiveData.fromImportMap(widget.diveData), - existingDiveId: widget.matchResult!.diveId, - matchScore: widget.matchResult!.score, - incomingLabel: 'Imported', - onSkip: () => widget.onResolutionChanged?.call( - DiveDuplicateResolution.skip, - ), - onImportAsNew: () => widget.onResolutionChanged?.call( - DiveDuplicateResolution.importAsNew, - ), - onConsolidate: () => widget.onResolutionChanged?.call( - DiveDuplicateResolution.consolidate, - ), - ), - ), - ], - ), - ); - } - - Widget _buildCheckbox(ColorScheme colorScheme) { - return Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: widget.isSelected ? colorScheme.primary : colorScheme.outline, - width: 2, - ), - color: widget.isSelected ? colorScheme.primary : Colors.transparent, - ), - child: widget.isSelected - ? Icon(Icons.check, size: 16, color: colorScheme.onPrimary) - : null, - ); - } - - Widget _buildMetrics( - ThemeData theme, - String depthStr, - String durationStr, - String? siteName, - ) { - final parts = [ - depthStr, - durationStr, - siteName ?? '', - ].where((s) => s.isNotEmpty).join(' / '); - - if (parts.isEmpty) return const SizedBox.shrink(); - - return Text( - parts, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } -} diff --git a/lib/features/universal_import/presentation/widgets/import_review_step.dart b/lib/features/universal_import/presentation/widgets/import_review_step.dart deleted file mode 100644 index 80b8cd46c..000000000 --- a/lib/features/universal_import/presentation/widgets/import_review_step.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:submersion/core/constants/enums.dart' as enums; -import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; -import 'package:submersion/features/universal_import/data/models/import_enums.dart'; -import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; -import 'package:submersion/features/universal_import/presentation/widgets/import_dive_card.dart'; -import 'package:submersion/features/universal_import/presentation/widgets/import_entity_card.dart'; - -/// Step 3: Tabbed entity selection with duplicate detection. -/// -/// Dynamically creates tabs based on which entity types the parser produced. -/// Each tab shows a list of items with selection checkboxes and duplicate -/// badges. Includes import button. -class ImportReviewStep extends ConsumerWidget { - const ImportReviewStep({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(universalImportNotifierProvider); - final theme = Theme.of(context); - final payload = state.payload; - - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (payload == null) return const SizedBox.shrink(); - - final types = state.availableTypes; - if (types.isEmpty) return const SizedBox.shrink(); - - return DefaultTabController( - length: types.length, - child: Column( - children: [ - // Duplicate summary banner - if (state.duplicateResult?.hasDuplicates == true) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Semantics( - label: context.l10n.universalImport_label_duplicatesFound( - state.duplicateResult!.totalDuplicates, - ), - child: Card( - color: theme.colorScheme.tertiaryContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - ExcludeSemantics( - child: Icon( - Icons.info_outline, - color: theme.colorScheme.onTertiaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - context.l10n.universalImport_label_duplicatesFound( - state.duplicateResult!.totalDuplicates, - ), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onTertiaryContainer, - ), - ), - ), - ], - ), - ), - ), - ), - ), - - // Entity type tabs - TabBar( - isScrollable: types.length > 4, - tabs: types.map((type) { - final count = state.totalCountFor(type); - return Tab(text: '${type.shortName} ($count)'); - }).toList(), - ), - - // Tab content - Expanded( - child: TabBarView( - children: types.map((type) { - return _EntityTypeList(type: type); - }).toList(), - ), - ), - - // Bottom bar - SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Semantics( - label: context.l10n.universalImport_semantics_itemsSelected( - state.totalSelected, - ), - child: Text( - context.l10n.universalImport_label_selected( - state.totalSelected, - ), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - const Spacer(), - FilledButton( - onPressed: state.totalSelected > 0 - ? () => ref - .read(universalImportNotifierProvider.notifier) - .performImport() - : null, - child: Text(context.l10n.universalImport_action_import), - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -/// Entity list for a single tab in the review step. -class _EntityTypeList extends ConsumerWidget { - const _EntityTypeList({required this.type}); - - final ImportEntityType type; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(universalImportNotifierProvider); - final theme = Theme.of(context); - final items = state.payload?.entitiesOf(type) ?? []; - final selection = state.selectionFor(type); - final notifier = ref.read(universalImportNotifierProvider.notifier); - - return Column( - children: [ - // Select/deselect all - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Semantics( - label: context.l10n.universalImport_semantics_entitySelection( - selection.length, - items.length, - type.shortName, - ), - child: Text( - context.l10n.universalImport_label_xOfYSelected( - selection.length, - items.length, - ), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - const Spacer(), - TextButton( - onPressed: selection.length == items.length - ? () => notifier.deselectAll(type) - : () => notifier.selectAll(type), - child: Text( - selection.length == items.length - ? context.l10n.universalImport_action_deselectAll - : context.l10n.universalImport_action_selectAll, - ), - ), - ], - ), - ), - // Item list - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final isSelected = selection.contains(index); - final dupResult = state.duplicateResult; - - if (type == ImportEntityType.dives) { - final matchResult = dupResult?.diveMatchFor(index); - return ImportDiveCard( - diveData: item, - index: index, - isSelected: isSelected, - onToggle: () => notifier.toggleSelection(type, index), - matchResult: matchResult, - resolution: state.diveResolutions[index], - onResolutionChanged: - matchResult != null && matchResult.score >= 0.5 - ? (res) => notifier.setDiveResolution(index, res) - : null, - ); - } - - final isDuplicate = dupResult?.isDuplicate(type, index) ?? false; - - return ImportEntityCard( - name: _getName(context, item), - subtitle: _getSubtitle(item), - icon: _iconFor(type), - isSelected: isSelected, - onToggle: () => notifier.toggleSelection(type, index), - isDuplicate: isDuplicate, - ); - }, - ), - ), - ], - ); - } - - String _getName(BuildContext context, Map item) { - return (item['name'] as String?) ?? - context.l10n.universalImport_label_unnamed; - } - - String? _getSubtitle(Map item) { - return switch (type) { - ImportEntityType.sites => _formatLocation( - item['latitude'] as double?, - item['longitude'] as double?, - ), - ImportEntityType.equipment => - (item['type'] as enums.EquipmentType?)?.displayName, - ImportEntityType.certifications => - (item['agency'] as enums.CertificationAgency?)?.displayName, - ImportEntityType.courses => item['agency'] as String?, - ImportEntityType.diveCenters => - item['country'] as String? ?? item['city'] as String?, - _ => null, - }; - } - - String? _formatLocation(double? lat, double? lon) { - if (lat == null || lon == null) return null; - return '${lat.toStringAsFixed(4)}, ${lon.toStringAsFixed(4)}'; - } - - static IconData _iconFor(ImportEntityType type) { - return switch (type) { - ImportEntityType.dives => Icons.scuba_diving, - ImportEntityType.sites => Icons.location_on_outlined, - ImportEntityType.trips => Icons.card_travel, - ImportEntityType.equipment => Icons.build_outlined, - ImportEntityType.equipmentSets => Icons.inventory_2_outlined, - ImportEntityType.buddies => Icons.person_outline, - ImportEntityType.diveCenters => Icons.store_outlined, - ImportEntityType.certifications => Icons.workspace_premium_outlined, - ImportEntityType.courses => Icons.school_outlined, - ImportEntityType.tags => Icons.label_outline, - ImportEntityType.diveTypes => Icons.category_outlined, - }; - } -} diff --git a/test/features/universal_import/data/models/import_enums_test.dart b/test/features/universal_import/data/models/import_enums_test.dart index 02c2fb39a..361c11f04 100644 --- a/test/features/universal_import/data/models/import_enums_test.dart +++ b/test/features/universal_import/data/models/import_enums_test.dart @@ -2,27 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; void main() { - group('DiveDuplicateResolution', () { - test('has three values', () { - expect(DiveDuplicateResolution.values, hasLength(3)); - }); - - test('skip displayName', () { - expect(DiveDuplicateResolution.skip.displayName, 'Skip'); - }); - - test('importAsNew displayName', () { - expect(DiveDuplicateResolution.importAsNew.displayName, 'Import as New'); - }); - - test('consolidate displayName', () { - expect( - DiveDuplicateResolution.consolidate.displayName, - 'Consolidate as additional computer', - ); - }); - }); - group('ImportFormat', () { test('has all expected values', () { expect(ImportFormat.values, hasLength(14)); diff --git a/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart b/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart index e2f65d56c..dfd0eadbe 100644 --- a/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart +++ b/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart @@ -3,55 +3,20 @@ import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqlite3/sqlite3.dart' as sqlite3; -import 'package:submersion/features/dive_log/data/services/dive_consolidation_service.dart'; -import 'package:submersion/features/dive_log/data/services/dive_merge_snapshot.dart'; -import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; -import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; -import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; -import 'package:submersion/features/divers/domain/entities/diver.dart'; -import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/universal_import/data/models/detection_result.dart'; import 'package:submersion/features/universal_import/data/models/field_mapping.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; -import 'package:submersion/features/universal_import/data/models/import_warning.dart'; import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; -import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; -import '../../../../helpers/test_database.dart'; -import 'import_consolidation_service_test.mocks.dart'; - -/// Minimal empty snapshot for stubbing [DiveConsolidationService.apply] -/// results -- mirrors the one in import_consolidation_service_test.dart. -const _emptyMergeSnapshot = DiveMergeSnapshot( - mergedDiveId: 'target-dive', - diveRows: [], - profileRows: [], - tankRows: [], - weightRows: [], - customFieldRows: [], - equipmentRows: [], - diveTypeRows: [], - tagRows: [], - buddyRows: [], - sightingRows: [], - eventRows: [], - gasSwitchRows: [], - tankPressureRows: [], - dataSourceRows: [], - tideRows: [], - mediaDiveIds: {}, -); - -/// Helper to encode a CSV string to bytes for testing. + Uint8List _csvBytes(String csv) => Uint8List.fromList(csv.codeUnits); /// Wait for the notifier's background async work (e.g. _parseAndCheckDuplicates) @@ -321,115 +286,6 @@ void main() { }); }); - group('setDiveResolution', () { - test('sets resolution for a dive index', () { - notifier.setDiveResolution(0, DiveDuplicateResolution.skip); - - expect(notifier.state.diveResolutions[0], DiveDuplicateResolution.skip); - }); - - test('skip resolution removes dive from selection', () { - // First add the dive to selection. - notifier.toggleSelection(ImportEntityType.dives, 0); - expect(notifier.state.selectionFor(ImportEntityType.dives), {0}); - - notifier.setDiveResolution(0, DiveDuplicateResolution.skip); - - expect( - notifier.state.selectionFor(ImportEntityType.dives), - isNot(contains(0)), - ); - }); - - test('importAsNew resolution adds dive to selection', () { - notifier.setDiveResolution(0, DiveDuplicateResolution.importAsNew); - - expect( - notifier.state.selectionFor(ImportEntityType.dives), - contains(0), - ); - expect( - notifier.state.diveResolutions[0], - DiveDuplicateResolution.importAsNew, - ); - }); - - test('consolidate resolution adds dive to selection', () { - notifier.setDiveResolution(0, DiveDuplicateResolution.consolidate); - - expect( - notifier.state.selectionFor(ImportEntityType.dives), - contains(0), - ); - expect( - notifier.state.diveResolutions[0], - DiveDuplicateResolution.consolidate, - ); - }); - - test('changing from importAsNew to skip removes from selection', () { - notifier.setDiveResolution(0, DiveDuplicateResolution.importAsNew); - expect( - notifier.state.selectionFor(ImportEntityType.dives), - contains(0), - ); - - notifier.setDiveResolution(0, DiveDuplicateResolution.skip); - - expect( - notifier.state.selectionFor(ImportEntityType.dives), - isNot(contains(0)), - ); - }); - - test('changing from skip to consolidate adds to selection', () { - notifier.setDiveResolution(0, DiveDuplicateResolution.skip); - expect( - notifier.state.selectionFor(ImportEntityType.dives), - isNot(contains(0)), - ); - - notifier.setDiveResolution(0, DiveDuplicateResolution.consolidate); - - expect( - notifier.state.selectionFor(ImportEntityType.dives), - contains(0), - ); - }); - - test('sets resolutions for multiple dive indices independently', () { - notifier.setDiveResolution(0, DiveDuplicateResolution.skip); - notifier.setDiveResolution(1, DiveDuplicateResolution.importAsNew); - notifier.setDiveResolution(2, DiveDuplicateResolution.consolidate); - - expect(notifier.state.diveResolutions[0], DiveDuplicateResolution.skip); - expect( - notifier.state.diveResolutions[1], - DiveDuplicateResolution.importAsNew, - ); - expect( - notifier.state.diveResolutions[2], - DiveDuplicateResolution.consolidate, - ); - - // Indices 1 and 2 should be selected, 0 should not. - final diveSelection = notifier.state.selectionFor( - ImportEntityType.dives, - ); - expect(diveSelection, isNot(contains(0))); - expect(diveSelection, contains(1)); - expect(diveSelection, contains(2)); - }); - - test('does not affect other entity type selections', () { - notifier.toggleSelection(ImportEntityType.sites, 0); - - notifier.setDiveResolution(0, DiveDuplicateResolution.importAsNew); - - expect(notifier.state.selectionFor(ImportEntityType.sites), {0}); - }); - }); - group('reset', () { test('resets to initial state', () { // Modify state in multiple ways. @@ -439,7 +295,6 @@ void main() { ); notifier.skipAdditionalFile(); notifier.toggleSelection(ImportEntityType.dives, 0); - notifier.setDiveResolution(1, DiveDuplicateResolution.consolidate); notifier.reset(); @@ -457,7 +312,6 @@ void main() { expect(notifier.state.payload, isNull); expect(notifier.state.duplicateResult, isNull); expect(notifier.state.selections, isEmpty); - expect(notifier.state.diveResolutions, isEmpty); expect(notifier.state.importCounts, isEmpty); }); @@ -474,7 +328,6 @@ void main() { expect(notifier.state.isImporting, defaultState.isImporting); expect(notifier.state.error, defaultState.error); expect(notifier.state.selections, defaultState.selections); - expect(notifier.state.diveResolutions, defaultState.diveResolutions); expect(notifier.state.importCounts, defaultState.importCounts); expect(notifier.state.importPhase, defaultState.importPhase); expect(notifier.state.importCurrent, defaultState.importCurrent); @@ -1552,212 +1405,5 @@ void main() { }, ); }); - - group('performImport - consolidation seam', () { - late Diver testDiver; - late MockDiveConsolidationService mockConsolidationService; - late ProviderContainer importContainer; - late UniversalImportNotifier importNotifier; - - /// Builds a payload with two minimal standalone dives so - /// UddfEntityImporter has enough to persist both as real dives. - ImportPayload buildTwoDivePayload() => ImportPayload( - entities: { - ImportEntityType.dives: [ - { - 'dateTime': DateTime.utc(2025, 1, 1, 10), - 'maxDepth': 18.0, - 'duration': const Duration(minutes: 40), - }, - { - 'dateTime': DateTime.utc(2025, 1, 2, 10), - 'maxDepth': 20.0, - 'duration': const Duration(minutes: 35), - }, - ], - }, - ); - - setUp(() async { - await setUpTestDatabase(); - - // Persist a real diver row: UddfEntityImporter writes dives with a - // diverId FK, so the diver must actually exist in the in-memory DB - // (not just be stubbed via provider override) or the insert fails - // FK validation. - testDiver = await DiverRepository().createDiver( - Diver( - id: 'diver-1', - name: 'Test Diver', - createdAt: DateTime.utc(2025, 1, 1), - updatedAt: DateTime.utc(2025, 1, 1), - ), - ); - - mockConsolidationService = MockDiveConsolidationService(); - when( - mockConsolidationService.apply( - targetDiveId: anyNamed('targetDiveId'), - secondaryDiveIds: anyNamed('secondaryDiveIds'), - ), - ).thenAnswer( - (invocation) async => DiveConsolidationOutcome( - targetDiveId: invocation.namedArguments[#targetDiveId] as String, - snapshot: _emptyMergeSnapshot, - ), - ); - - SharedPreferences.setMockInitialValues({}); - final importPrefs = await SharedPreferences.getInstance(); - - importContainer = ProviderContainer( - overrides: [ - sharedPreferencesProvider.overrideWithValue(importPrefs), - diveConsolidationServiceProvider.overrideWithValue( - mockConsolidationService, - ), - currentDiverProvider.overrideWith((ref) async => testDiver), - ], - ); - importNotifier = importContainer.read( - universalImportNotifierProvider.notifier, - ); - }); - - tearDown(() async { - importContainer.dispose(); - await tearDownTestDatabase(); - }); - - test('consolidate-marked index reaches performConsolidations and is ' - 'folded into its matched dive, while the plain-selected index is ' - 'never passed to the consolidation service', () async { - importNotifier.state = importNotifier.state.copyWith( - payload: buildTwoDivePayload(), - selections: { - ImportEntityType.dives: {0, 1}, - }, - diveResolutions: {1: DiveDuplicateResolution.consolidate}, - duplicateResult: const ImportDuplicateResult( - diveMatches: { - 1: DiveMatchResult( - diveId: 'existing-dive-1', - score: 0.9, - timeDifferenceMs: 100, - ), - }, - ), - ); - - await importNotifier.performImport(); - - expect(importNotifier.state.error, isNull); - // Both dives were imported as standalone dives (index 0 stays - // standalone, index 1 was also imported before being folded). - expect(importNotifier.state.importCounts[ImportEntityType.dives], 2); - - // Only the consolidate-flagged index (1) should have reached - // DiveConsolidationService.apply -- exactly once, targeting the - // dive it matched against. - final captured = verify( - mockConsolidationService.apply( - targetDiveId: captureAnyNamed('targetDiveId'), - secondaryDiveIds: captureAnyNamed('secondaryDiveIds'), - ), - ).captured; - expect(captured, hasLength(2)); - expect(captured[0], 'existing-dive-1'); - expect(captured[1], hasLength(1)); - }); - - test('a failed consolidation produces the failed-consolidation ' - 'ImportWarning with the exact expected wording', () async { - when( - mockConsolidationService.apply( - targetDiveId: anyNamed('targetDiveId'), - secondaryDiveIds: anyNamed('secondaryDiveIds'), - ), - ).thenThrow(ArgumentError('targetDiveId not in selection')); - - importNotifier.state = importNotifier.state.copyWith( - payload: buildTwoDivePayload(), - selections: { - ImportEntityType.dives: {0, 1}, - }, - diveResolutions: {1: DiveDuplicateResolution.consolidate}, - duplicateResult: const ImportDuplicateResult( - diveMatches: { - 1: DiveMatchResult( - diveId: 'existing-dive-1', - score: 0.9, - timeDifferenceMs: 100, - ), - }, - ), - ); - - await importNotifier.performImport(); - - // The consolidation failure must not surface as a hard error -- - // it's compensated (standalone dive deleted) and reported as a - // warning attached to the payload instead. - expect(importNotifier.state.error, isNull); - expect(importNotifier.state.payload, isNotNull); - expect( - importNotifier.state.payload!.warnings, - contains( - const ImportWarning( - severity: ImportWarningSeverity.error, - message: - '1 dive(s) could not be consolidated into their matched ' - 'dive; the partial imports were removed again to avoid ' - 'duplicates.', - entityType: ImportEntityType.dives, - ), - ), - ); - }); - - test( - 'zero consolidation failures produce no consolidation warning', - () async { - final inputPayload = buildTwoDivePayload(); - importNotifier.state = importNotifier.state.copyWith( - payload: inputPayload, - selections: { - ImportEntityType.dives: {0, 1}, - }, - diveResolutions: {1: DiveDuplicateResolution.consolidate}, - duplicateResult: const ImportDuplicateResult( - diveMatches: { - 1: DiveMatchResult( - diveId: 'existing-dive-1', - score: 0.9, - timeDifferenceMs: 100, - ), - }, - ), - ); - - await importNotifier.performImport(); - - expect(importNotifier.state.error, isNull); - verify( - mockConsolidationService.apply( - targetDiveId: anyNamed('targetDiveId'), - secondaryDiveIds: anyNamed('secondaryDiveIds'), - ), - ).called(1); - // When there are no consolidation failures, performImport passes - // `payload: null` to copyWith without the `clearPayload` sentinel, - // which copyWith's `clearPayload ? null : (payload ?? this.payload)` - // treats as "leave unchanged" -- so the payload is left exactly as - // it was before the import ran, with no consolidation warning - // appended. - expect(importNotifier.state.payload, inputPayload); - expect(importNotifier.state.payload!.warnings, isEmpty); - }, - ); - }); }); } From a12fa71727013a5fde7bf323aa6fc9041f478021 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 6 Jul 2026 11:06:28 -0400 Subject: [PATCH 4/4] refactor(matching): extract shared MatchScorer for both dive matchers --- lib/core/matching/match_scorer.dart | 101 +++++++++++++++++ .../domain/services/dive_matcher.dart | 105 +++++++----------- .../dive_computer_repository_impl.dart | 53 +++++---- test/core/matching/match_scorer_test.dart | 93 ++++++++++++++++ 4 files changed, 261 insertions(+), 91 deletions(-) create mode 100644 lib/core/matching/match_scorer.dart create mode 100644 test/core/matching/match_scorer_test.dart diff --git a/lib/core/matching/match_scorer.dart b/lib/core/matching/match_scorer.dart new file mode 100644 index 000000000..716ba17a6 --- /dev/null +++ b/lib/core/matching/match_scorer.dart @@ -0,0 +1,101 @@ +/// Shared scoring primitives for fuzzy dive matching. +/// +/// Both the file-import matcher (`DiveMatcher`) and the dive-computer download +/// matcher (`DiveComputerRepository.findMatchingDiveWithScore`) score a +/// candidate pair on time, depth, and duration, then combine the three with +/// weights. They differ only in their weights, breakpoints, units, and +/// missing-data handling — all of which are expressed as configuration here, so +/// the linear-falloff math lives in exactly one place. +library; + +/// Linear-falloff sub-score in the range 0.0–1.0. +/// +/// Returns 1.0 when [value] is at or below [full], 0.0 when at or above [zero], +/// and a straight-line interpolation in between. [zero] must be greater than or +/// equal to [full]; when they are equal the function degenerates to a step at +/// that point. [value] may be `double.infinity` to force a 0.0 score (used by +/// the percent-depth matcher when the existing depth is non-positive). +double bandScore(double value, {required double full, required double zero}) { + if (value <= full) return 1.0; + if (value >= zero) return 0.0; + return 1.0 - ((value - full) / (zero - full)); +} + +/// A weighted match scorer parameterized by per-component weights and +/// [bandScore] breakpoints. +/// +/// Callers compute each raw component *value* in the units that match this +/// scorer's breakpoints (e.g. minutes vs. milliseconds for time, a fraction vs. +/// meters for depth) and pass them to [score]; missing-data and guard handling +/// is done by the caller by choosing a sentinel value (0 to score 1.0 via a +/// `full: 0` band, `double.infinity` to score 0.0). This keeps the unit and +/// null semantics — which genuinely differ between the two matchers — at the +/// call site, while the falloff and weighting are shared. +class MatchScorer { + /// Weight applied to the time sub-score (weights should sum to ~1.0). + final double timeWeight; + + /// Weight applied to the depth sub-score. + final double depthWeight; + + /// Weight applied to the duration sub-score. + final double durationWeight; + + /// [bandScore] `full`/`zero` breakpoints for the time component. + final double timeFull; + final double timeZero; + + /// [bandScore] `full`/`zero` breakpoints for the depth component. + final double depthFull; + final double depthZero; + + /// [bandScore] `full`/`zero` breakpoints for the duration component. + final double durationFull; + final double durationZero; + + /// When true, a zero time sub-score short-circuits the whole score to 0.0. + /// + /// Time is then a NECESSARY condition: two recordings with no time overlap in + /// evidence cannot be the same physical dive, so a depth + duration + /// coincidence must not be able to reach the match threshold. The file-import + /// matcher enables this; the download matcher does not (its SQL pre-filter + /// already bounds candidates to the tolerance window). + final bool gateOnZeroTime; + + const MatchScorer({ + required this.timeWeight, + required this.depthWeight, + required this.durationWeight, + required this.timeFull, + required this.timeZero, + required this.depthFull, + required this.depthZero, + required this.durationFull, + required this.durationZero, + this.gateOnZeroTime = false, + }); + + /// Compute the weighted composite score for a candidate pair. + /// + /// Each `*Value` must already be expressed in the units of the corresponding + /// breakpoints (see the class doc). + double score({ + required double timeValue, + required double depthValue, + required double durationValue, + }) { + final timeScore = bandScore(timeValue, full: timeFull, zero: timeZero); + if (gateOnZeroTime && timeScore <= 0) return 0.0; + + final depthScore = bandScore(depthValue, full: depthFull, zero: depthZero); + final durationScore = bandScore( + durationValue, + full: durationFull, + zero: durationZero, + ); + + return (timeScore * timeWeight) + + (depthScore * depthWeight) + + (durationScore * durationWeight); + } +} diff --git a/lib/features/dive_import/domain/services/dive_matcher.dart b/lib/features/dive_import/domain/services/dive_matcher.dart index 28240914e..f25e7834f 100644 --- a/lib/features/dive_import/domain/services/dive_matcher.dart +++ b/lib/features/dive_import/domain/services/dive_matcher.dart @@ -1,3 +1,5 @@ +import 'package:submersion/core/matching/match_scorer.dart'; + /// Service for matching imported dives to existing dive log entries. /// /// Uses fuzzy matching based on time, depth, and duration to detect @@ -6,6 +8,24 @@ class DiveMatcher { /// Creates a [DiveMatcher] instance. const DiveMatcher(); + /// Weighted scorer for file imports: time 50%, depth 30%, duration 20%. + /// + /// Time is scored over 5-15 min (whole minutes), depth as a percentage + /// (10%-20%), and duration over 3-10 min; a zero time score gates the whole + /// match to 0.0 (see [MatchScorer.gateOnZeroTime]). + static const _scorer = MatchScorer( + timeWeight: 0.50, + depthWeight: 0.30, + durationWeight: 0.20, + timeFull: 5, // minutes + timeZero: 15, + depthFull: 0.10, // fraction + depthZero: 0.20, + durationFull: 3, // minutes + durationZero: 10, + gateOnZeroTime: true, + ); + /// Calculate a match score between an imported dive and an existing dive. /// /// Returns a score from 0.0 (no match) to 1.0 (perfect match). @@ -22,74 +42,25 @@ class DiveMatcher { required double existingMaxDepth, required int existingDurationSeconds, }) { - final timeScore = _calculateTimeScore(wearableStartTime, existingStartTime); - - // Time is a NECESSARY condition, not just a weighted term. - // `_calculateTimeScore` is 0.0 once the two starts are >= 15 min apart; - // with no time evidence, a depth + duration coincidence (0.30 + 0.20 = - // 0.50) must not be able to reach the possible-duplicate threshold. Two - // recordings that do not line up in time cannot be the same physical dive. - if (timeScore <= 0) return 0.0; - - final depthScore = _calculateDepthScore(wearableMaxDepth, existingMaxDepth); - final durationScore = _calculateDurationScore( - wearableDurationSeconds, - existingDurationSeconds, + final timeMinutes = wearableStartTime + .difference(existingStartTime) + .abs() + .inMinutes + .toDouble(); + // Percent depth difference. A non-positive existing depth is treated as + // maximally different (infinity -> depth score 0), preserving the previous + // zero/negative-depth guard. + final depthValue = existingMaxDepth <= 0 + ? double.infinity + : (wearableMaxDepth - existingMaxDepth).abs() / existingMaxDepth; + final durationMinutes = + (wearableDurationSeconds - existingDurationSeconds).abs() / 60.0; + + return _scorer.score( + timeValue: timeMinutes, + depthValue: depthValue, + durationValue: durationMinutes, ); - - // Weighted composite score - return (timeScore * 0.50) + (depthScore * 0.30) + (durationScore * 0.20); - } - - /// Calculate time score: within 5 min = 100%, 15 min = 0% - double _calculateTimeScore(DateTime wearableTime, DateTime existingTime) { - final timeDiff = wearableTime.difference(existingTime).abs(); - final timeMinutes = timeDiff.inMinutes; - - if (timeMinutes <= 5) { - return 1.0; - } else if (timeMinutes >= 15) { - return 0.0; - } else { - return 1.0 - ((timeMinutes - 5) / 10); - } - } - - /// Calculate depth score: within 10% = 100%, 20%+ diff = 0% - double _calculateDepthScore(double wearableDepth, double existingDepth) { - if (existingDepth <= 0) { - // Handle edge case of zero or negative depth - return 0.0; - } - - final depthDiff = (wearableDepth - existingDepth).abs(); - final depthPercent = depthDiff / existingDepth; - - if (depthPercent <= 0.10) { - return 1.0; - } else if (depthPercent >= 0.20) { - return 0.0; - } else { - return 1.0 - ((depthPercent - 0.10) / 0.10); - } - } - - /// Calculate duration score: within 3 min = 100%, 10 min = 0% - double _calculateDurationScore( - int wearableDurationSeconds, - int existingDurationSeconds, - ) { - final durationDiff = (wearableDurationSeconds - existingDurationSeconds) - .abs(); - final durationDiffMinutes = durationDiff / 60; - - if (durationDiffMinutes <= 3) { - return 1.0; - } else if (durationDiffMinutes >= 10) { - return 0.0; - } else { - return 1.0 - ((durationDiffMinutes - 3) / 7); - } } /// Check if the score indicates a probable duplicate (high confidence). 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 b4bf1b844..e7336d6f8 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 @@ -16,6 +16,7 @@ import 'package:submersion/core/database/database.dart' DiveProfile, DiveProfileEvent, TankPressureProfilesCompanion; +import 'package:submersion/core/matching/match_scorer.dart'; import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/sync/sync_event_bus.dart'; @@ -667,6 +668,23 @@ class DiveComputerRepository { if (result.isEmpty) return null; + // Weighted scorer for downloads: time 40%, depth 35%, duration 25%. + // Time is scored in milliseconds over the SQL tolerance window, depth in + // absolute meters (0-5 m), duration in seconds (0-10 min). Missing depth + // or duration scores 1.0 (a `full: 0` band on a 0 value). No time gate: + // the SQL pre-filter already bounds candidates to the tolerance window. + final scorer = MatchScorer( + timeWeight: 0.40, + depthWeight: 0.35, + durationWeight: 0.25, + timeFull: 0, + timeZero: toleranceMs.toDouble(), + depthFull: 0, + depthZero: 5.0, + durationFull: 0, + durationZero: 600, + ); + // Score each candidate and find the best match DiveMatchResult? bestMatch; double bestScore = 0.0; @@ -677,31 +695,18 @@ class DiveComputerRepository { final diveDuration = row.data['bottom_time'] as int?; final diveMaxDepth = row.data['max_depth'] as double?; - // Calculate component scores - final timeScore = 1.0 - (timeDiff / toleranceMs).clamp(0.0, 1.0); - var durationScore = 1.0; - var depthScore = 1.0; - int? durationDiff; - double? depthDiff; - - // Duration comparison (if available) - if (durationSeconds != null && diveDuration != null) { - durationDiff = (diveDuration - durationSeconds).abs(); - // Score based on duration difference (within 5 min = 100%, 10 min = 0%) - durationScore = 1.0 - (durationDiff / 600).clamp(0.0, 1.0); - } - - // Depth comparison (if available) - if (maxDepth != null && diveMaxDepth != null) { - depthDiff = (diveMaxDepth - maxDepth).abs(); - // Score based on depth difference (within 0.5m = 100%, 5m = 0%) - depthScore = 1.0 - (depthDiff / 5.0).clamp(0.0, 1.0); - } + final durationDiff = (durationSeconds != null && diveDuration != null) + ? (diveDuration - durationSeconds).abs() + : null; + final depthDiff = (maxDepth != null && diveMaxDepth != null) + ? (diveMaxDepth - maxDepth).abs() + : null; - // Weighted composite score - // Time is most important (40%), then depth (35%), then duration (25%) - final score = - (timeScore * 0.40) + (depthScore * 0.35) + (durationScore * 0.25); + final score = scorer.score( + timeValue: timeDiff.toDouble(), + depthValue: depthDiff ?? 0.0, + durationValue: durationDiff?.toDouble() ?? 0.0, + ); if (score > bestScore) { bestScore = score; diff --git a/test/core/matching/match_scorer_test.dart b/test/core/matching/match_scorer_test.dart new file mode 100644 index 000000000..669396add --- /dev/null +++ b/test/core/matching/match_scorer_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/matching/match_scorer.dart'; + +void main() { + group('bandScore', () { + test('returns 1.0 at or below full', () { + expect(bandScore(0, full: 5, zero: 15), 1.0); + expect(bandScore(5, full: 5, zero: 15), 1.0); + }); + + test('returns 0.0 at or above zero', () { + expect(bandScore(15, full: 5, zero: 15), 0.0); + expect(bandScore(100, full: 5, zero: 15), 0.0); + }); + + test('interpolates linearly between full and zero', () { + // 8 on [5, 15] -> 1 - (8-5)/(15-5) = 0.7 + expect(bandScore(8, full: 5, zero: 15), closeTo(0.7, 1e-9)); + // midpoint + expect(bandScore(10, full: 5, zero: 15), closeTo(0.5, 1e-9)); + }); + + test('a full: 0 band scores a 0 value as 1.0 (missing-data sentinel)', () { + expect(bandScore(0, full: 0, zero: 5), 1.0); + }); + + test('infinity scores 0.0 (percent-depth guard sentinel)', () { + expect(bandScore(double.infinity, full: 0.10, zero: 0.20), 0.0); + }); + }); + + group('MatchScorer.score', () { + const scorer = MatchScorer( + timeWeight: 0.50, + depthWeight: 0.30, + durationWeight: 0.20, + timeFull: 5, + timeZero: 15, + depthFull: 0.10, + depthZero: 0.20, + durationFull: 3, + durationZero: 10, + ); + + test('perfect match scores 1.0', () { + expect( + scorer.score(timeValue: 0, depthValue: 0, durationValue: 0), + closeTo(1.0, 1e-9), + ); + }); + + test('weights each sub-score', () { + // time 8min -> 0.7, depth/duration perfect -> composite + // 0.7*0.5 + 1*0.3 + 1*0.2 = 0.85 + expect( + scorer.score(timeValue: 8, depthValue: 0, durationValue: 0), + closeTo(0.85, 1e-9), + ); + }); + + test( + 'without gate, zero time still allows depth+duration to contribute', + () { + // time >= 15 -> timeScore 0; depth+duration perfect -> 0.3 + 0.2 = 0.5 + expect( + scorer.score(timeValue: 20, depthValue: 0, durationValue: 0), + closeTo(0.5, 1e-9), + ); + }, + ); + + test('gateOnZeroTime short-circuits to 0.0 when time score is zero', () { + const gated = MatchScorer( + timeWeight: 0.50, + depthWeight: 0.30, + durationWeight: 0.20, + timeFull: 5, + timeZero: 15, + depthFull: 0.10, + depthZero: 0.20, + durationFull: 3, + durationZero: 10, + gateOnZeroTime: true, + ); + expect(gated.score(timeValue: 20, depthValue: 0, durationValue: 0), 0.0); + // still scores normally within the window + expect( + gated.score(timeValue: 8, depthValue: 0, durationValue: 0), + closeTo(0.85, 1e-9), + ); + }); + }); +}