diff --git a/lib/core/services/export/uddf/uddf_full_import_service.dart b/lib/core/services/export/uddf/uddf_full_import_service.dart index 4f0512ab5..a5c392e38 100644 --- a/lib/core/services/export/uddf/uddf_full_import_service.dart +++ b/lib/core/services/export/uddf/uddf_full_import_service.dart @@ -1,6 +1,7 @@ import 'package:xml/xml.dart'; import 'package:submersion/core/constants/enums.dart' as enums; +import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/export/models/uddf_import_result.dart'; import 'package:submersion/core/services/export/uddf/uddf_import_parsers.dart'; import 'package:submersion/core/services/export/uddf/uddf_normalizer.dart'; @@ -13,6 +14,8 @@ import 'package:submersion/features/dive_log/domain/entities/dive.dart'; /// Delegates base parsing to [UddfImportService] and entity parsing /// to [UddfImportParsers]. class UddfFullImportService { + static final _logger = LoggerService.forClass(UddfFullImportService); + /// Import ALL application data from UDDF file. /// Returns [UddfImportResult] with all parsed data. Future importAllDataFromUddf(String uddfContent) async { @@ -1244,9 +1247,16 @@ class UddfFullImportService { final tankInfo = {}; // Capture tank ID for reference mapping (used by waypoint tankpressure refs) - final tankId = tankDataElement.getAttribute('id'); - if (tankId != null) { + final rawTankId = tankDataElement.getAttribute('id'); + final tankId = rawTankId?.trim(); + if (tankId != null && tankId.isNotEmpty) { tankInfo['uddfTankId'] = tankId; + } else { + _logger.debug( + 'UDDF import: is missing or has empty/whitespace ' + 'required "id" attribute; ' + 'falling back to ordered tank ref resolution.', + ); } // Get tank volume (in liters) @@ -1402,6 +1412,12 @@ class UddfFullImportService { tankRefToIndex[uddfTankId] = i; } } + final fallbackTankIndices = [ + for (var i = 0; i < tanks.length; i++) + if (tanks[i]['uddfTankId'] == null) i, + ]; + final fallbackRefToIndex = {}; + var nextFallbackTankIndex = 0; if (tanks.isNotEmpty) { diveData['tanks'] = tanks; @@ -1462,6 +1478,21 @@ class UddfFullImportService { int tankIdx; if (tankRef != null && tankRefToIndex.containsKey(tankRef)) { tankIdx = tankRefToIndex[tankRef]!; + } else if (tankRef != null) { + final fallbackTankIndex = fallbackRefToIndex[tankRef]; + if (fallbackTankIndex != null) { + tankIdx = fallbackTankIndex; + } else if (nextFallbackTankIndex < fallbackTankIndices.length) { + tankIdx = fallbackTankIndices[nextFallbackTankIndex++]; + fallbackRefToIndex[tankRef] = tankIdx; + } else { + _logger.debug( + 'UDDF import: ${tanks.length} tank records but ' + '${fallbackRefToIndex.length + 1} unique unmatched tank refs; ' + 'dropping ref "$tankRef" from import.', + ); + continue; + } } else { // Default to primary tank (index 0) when no ref attribute tankIdx = 0; diff --git a/lib/core/services/export/uddf/uddf_import_service.dart b/lib/core/services/export/uddf/uddf_import_service.dart index d07d98a77..fc7b17b1d 100644 --- a/lib/core/services/export/uddf/uddf_import_service.dart +++ b/lib/core/services/export/uddf/uddf_import_service.dart @@ -1,6 +1,7 @@ import 'package:xml/xml.dart'; import 'package:submersion/core/constants/enums.dart' as enums; +import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/export/uddf/uddf_import_parsers.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; @@ -9,6 +10,8 @@ import 'package:submersion/features/dive_log/domain/entities/dive.dart'; /// Parses standard UDDF elements (diver, divesite, gasdefinitions, /// decomodel, profiledata) and returns dive and site maps. class UddfImportService { + static final _logger = LoggerService.forClass(UddfImportService); + Future>>> importDivesFromUddf( String uddfContent, ) async { @@ -367,9 +370,16 @@ class UddfImportService { final tankInfo = {}; // Capture tank ID for reference mapping (used by waypoint tankpressure refs) - final tankId = tankDataElement.getAttribute('id'); - if (tankId != null) { + final rawTankId = tankDataElement.getAttribute('id'); + final tankId = rawTankId?.trim(); + if (tankId != null && tankId.isNotEmpty) { tankInfo['uddfTankId'] = tankId; + } else { + _logger.debug( + 'UDDF import: is missing or has empty/whitespace ' + 'required "id" attribute; ' + 'falling back to ordered tank ref resolution.', + ); } // Get tank volume (in liters) @@ -510,6 +520,12 @@ class UddfImportService { tankRefToIndex[uddfTankId] = i; } } + final fallbackTankIndices = [ + for (var i = 0; i < tanks.length; i++) + if (tanks[i]['uddfTankId'] == null) i, + ]; + final fallbackRefToIndex = {}; + var nextFallbackTankIndex = 0; if (tanks.isNotEmpty) { diveData['tanks'] = tanks; @@ -567,6 +583,21 @@ class UddfImportService { int tankIdx; if (tankRef != null && tankRefToIndex.containsKey(tankRef)) { tankIdx = tankRefToIndex[tankRef]!; + } else if (tankRef != null) { + final fallbackTankIndex = fallbackRefToIndex[tankRef]; + if (fallbackTankIndex != null) { + tankIdx = fallbackTankIndex; + } else if (nextFallbackTankIndex < fallbackTankIndices.length) { + tankIdx = fallbackTankIndices[nextFallbackTankIndex++]; + fallbackRefToIndex[tankRef] = tankIdx; + } else { + _logger.debug( + 'UDDF import: ${tanks.length} tank records but ' + '${fallbackRefToIndex.length + 1} unique unmatched tank refs; ' + 'dropping ref "$tankRef" from import.', + ); + continue; + } } else { // Default to primary tank (index 0) when no ref attribute tankIdx = 0; diff --git a/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart b/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart index 794428d9f..1391cf0a1 100644 --- a/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart +++ b/lib/features/dive_log/presentation/widgets/dive_profile_chart.dart @@ -368,6 +368,7 @@ class _DiveProfileChartState extends ConsumerState { _showCeiling = widget.showCeiling; _showAscentRateColors = widget.showAscentRateColors; _showEvents = widget.showEvents; + _scheduleTankPressureVisibilityInitialization(); } @override @@ -377,6 +378,18 @@ class _DiveProfileChartState extends ConsumerState { _lastTooltipSpotIndex = null; _lastTooltipItems = []; } + if (oldWidget.tankPressures != widget.tankPressures) { + _scheduleTankPressureVisibilityInitialization(); + } + } + + void _scheduleTankPressureVisibilityInitialization() { + if (!_hasMultiTankPressure) return; + final tankIds = widget.tankPressures!.keys.toList(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || tankIds.isEmpty) return; + ref.read(profileLegendProvider.notifier).initializeTankPressures(tankIds); + }); } void _resetZoom() { @@ -1988,7 +2001,7 @@ class _DiveProfileChartState extends ConsumerState { final tankId = sortedTankIds[i]; // Skip if tank is hidden - if (!(_showTankPressure[tankId] ?? true)) continue; + if (_showTankPressure[tankId] == false) continue; final pressurePoints = tankPressures[tankId]!; if (pressurePoints.isEmpty) continue; diff --git a/test/core/services/export/uddf/uddf_full_import_service_test.dart b/test/core/services/export/uddf/uddf_full_import_service_test.dart new file mode 100644 index 000000000..40901acd3 --- /dev/null +++ b/test/core/services/export/uddf/uddf_full_import_service_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/export/uddf/uddf_full_import_service.dart'; + +void main() { + group('UddfFullImportService', () { + test( + 'maps tankpressure refs by tank order when tankdata entries omit ids', + () async { + const uddfContent = ''' + + + + + + 2025-09-01T14:18:24Z + 235 + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + + 1 + 0 + 20049962 + 21952916 + + + + + + +'''; + + final service = UddfFullImportService(); + + final result = await service.importAllDataFromUddf(uddfContent); + expect(result.dives, hasLength(1)); + + final dive = result.dives.first; + final tanks = dive['tanks'] as List>; + final profile = dive['profile'] as List>; + final firstPointPressures = + profile.first['allTankPressures'] as List>; + + expect(tanks, hasLength(2)); + expect(tanks[0]['uddfTankId'], isNull); + expect(tanks[1]['uddfTankId'], isNull); + + expect(firstPointPressures, hasLength(2)); + expect(firstPointPressures[0]['tankIndex'], 0); + expect(firstPointPressures[1]['tankIndex'], 1); + expect(firstPointPressures[0]['pressure'], closeTo(200.5, 0.1)); + expect(firstPointPressures[1]['pressure'], closeTo(219.5, 0.1)); + }, + ); + + test( + 'treats empty and whitespace tankdata ids as missing for fallback mapping', + () async { + const uddfContent = ''' + + + + + + 2025-09-01T14:18:24Z + 235 + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + + 1 + 0 + 20049962 + 21952916 + + + + + + +'''; + + final service = UddfFullImportService(); + + final result = await service.importAllDataFromUddf(uddfContent); + expect(result.dives, hasLength(1)); + + final dive = result.dives.first; + final tanks = dive['tanks'] as List>; + final profile = dive['profile'] as List>; + final firstPointPressures = + profile.first['allTankPressures'] as List>; + + expect(tanks, hasLength(2)); + expect(tanks[0]['uddfTankId'], isNull); + expect(tanks[1]['uddfTankId'], isNull); + expect(firstPointPressures, hasLength(2)); + expect(firstPointPressures[0]['tankIndex'], 0); + expect(firstPointPressures[1]['tankIndex'], 1); + }, + ); + + test('drops extra unmatched refs beyond available tank records', () async { + const uddfContent = ''' + + + + + + 2025-09-01T14:18:24Z + 235 + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + + 1 + 0 + 20049962 + 21952916 + 15000000 + + + + + + +'''; + + final service = UddfFullImportService(); + + final result = await service.importAllDataFromUddf(uddfContent); + final dive = result.dives.first; + final profile = dive['profile'] as List>; + final firstPointPressures = + profile.first['allTankPressures'] as List>; + + expect(firstPointPressures, hasLength(2)); + expect(firstPointPressures[0]['tankIndex'], 0); + expect(firstPointPressures[1]['tankIndex'], 1); + }); + }); +} diff --git a/test/core/services/export/uddf/uddf_import_service_test.dart b/test/core/services/export/uddf/uddf_import_service_test.dart new file mode 100644 index 000000000..73e8365b8 --- /dev/null +++ b/test/core/services/export/uddf/uddf_import_service_test.dart @@ -0,0 +1,238 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/export/uddf/uddf_import_service.dart'; + +void main() { + group('UddfImportService', () { + test( + 'maps T1/T2 refs to tanks by order when tankdata entries omit ids', + () async { + const uddfContent = ''' + + + + + + 2025-09-01T14:18:24Z + 235 + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + + 1 + 0 + 20049962 + 21952916 + + + 3 + 10 + 19939646 + 21939126 + + + + + + +'''; + + final service = UddfImportService(); + + final result = await service.importDivesFromUddf(uddfContent); + final dives = result['dives']!; + + expect(dives, hasLength(1)); + + final dive = dives.first; + final tanks = dive['tanks'] as List>; + final profile = dive['profile'] as List>; + + expect(tanks, hasLength(2)); + expect(tanks[0]['uddfTankId'], isNull); + expect(tanks[1]['uddfTankId'], isNull); + + final firstPointPressures = + profile.first['allTankPressures'] as List>; + final secondPointPressures = + profile.last['allTankPressures'] as List>; + + expect(firstPointPressures, hasLength(2)); + expect(firstPointPressures[0]['tankIndex'], 0); + expect(firstPointPressures[1]['tankIndex'], 1); + expect(firstPointPressures[0]['pressure'], closeTo(200.5, 0.1)); + expect(firstPointPressures[1]['pressure'], closeTo(219.5, 0.1)); + + expect(secondPointPressures, hasLength(2)); + expect(secondPointPressures[0]['tankIndex'], 0); + expect(secondPointPressures[1]['tankIndex'], 1); + }, + ); + + test( + 'treats empty and whitespace tankdata ids as missing for fallback mapping', + () async { + const uddfContent = ''' + + + + + + 2025-09-01T14:18:24Z + 235 + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + + 1 + 0 + 20049962 + 21952916 + + + + + + +'''; + + final service = UddfImportService(); + + final result = await service.importDivesFromUddf(uddfContent); + final dives = result['dives']!; + expect(dives, hasLength(1)); + + final dive = dives.first; + final tanks = dive['tanks'] as List>; + final profile = dive['profile'] as List>; + final firstPointPressures = + profile.first['allTankPressures'] as List>; + + expect(tanks, hasLength(2)); + expect(tanks[0]['uddfTankId'], isNull); + expect(tanks[1]['uddfTankId'], isNull); + expect(firstPointPressures, hasLength(2)); + expect(firstPointPressures[0]['tankIndex'], 0); + expect(firstPointPressures[1]['tankIndex'], 1); + }, + ); + + test( + 'maps non-T refs by tank order when tankdata entries omit ids', + () async { + const uddfContent = ''' + + + + + + 2025-09-01T14:18:24Z + 235 + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + + 1 + 0 + 20049962 + 21952916 + + + + + + +'''; + + final service = UddfImportService(); + + final result = await service.importDivesFromUddf(uddfContent); + final dives = result['dives']!; + + expect(dives, hasLength(1)); + + final dive = dives.first; + final tanks = dive['tanks'] as List>; + final profile = dive['profile'] as List>; + final firstPointPressures = + profile.first['allTankPressures'] as List>; + + expect(tanks, hasLength(2)); + expect(tanks[0]['uddfTankId'], isNull); + expect(tanks[1]['uddfTankId'], isNull); + + expect(firstPointPressures, hasLength(2)); + expect(firstPointPressures[0]['tankIndex'], 0); + expect(firstPointPressures[1]['tankIndex'], 1); + expect(firstPointPressures[0]['pressure'], closeTo(200.5, 0.1)); + expect(firstPointPressures[1]['pressure'], closeTo(219.5, 0.1)); + }, + ); + + test('drops unmatched refs beyond available tank records', () async { + const uddfContent = ''' + + + + + + 2025-09-01T14:18:24Z + 235 + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + + 1 + 0 + 20049962 + 21952916 + 15000000 + + + + + + +'''; + + final service = UddfImportService(); + + final result = await service.importDivesFromUddf(uddfContent); + final dive = result['dives']!.first; + final profile = dive['profile'] as List>; + final firstPointPressures = + profile.first['allTankPressures'] as List>; + + expect(firstPointPressures, hasLength(2)); + expect(firstPointPressures[0]['tankIndex'], 0); + expect(firstPointPressures[1]['tankIndex'], 1); + }); + }); +} diff --git a/test/features/dive_import/data/services/uddf_entity_importer_test.dart b/test/features/dive_import/data/services/uddf_entity_importer_test.dart index 39ab17516..673c60767 100644 --- a/test/features/dive_import/data/services/uddf_entity_importer_test.dart +++ b/test/features/dive_import/data/services/uddf_entity_importer_test.dart @@ -715,6 +715,69 @@ void main() { expect(dive.notes, contains('Great dive')); expect(dive.notes, contains('Weight used: 4.5 kg')); }); + + test( + 'imports dive with two tanks and stores pressure data for both', + () async { + when(mockDiveRepo.createDive(any)).thenAnswer( + (invocation) async => invocation.positionalArguments[0] as Dive, + ); + when( + mockTankPressureRepo.insertTankPressures(any, any), + ).thenAnswer((_) async {}); + + final data = UddfImportResult( + dives: [ + { + 'dateTime': now, + 'maxDepth': 30.0, + 'tanks': [ + {'uddfTankId': 'T1', 'volume': 12.0}, + {'uddfTankId': 'T2', 'volume': 11.0}, + ], + 'profile': [ + { + 'timestamp': 0, + 'depth': 0.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 200.0}, + {'tankIndex': 1, 'pressure': 190.0}, + ], + }, + { + 'timestamp': 60, + 'depth': 20.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 180.0}, + {'tankIndex': 1, 'pressure': 170.0}, + ], + }, + ], + }, + ], + ); + + await importer.import( + data: data, + selections: UddfImportSelections.selectAll(data), + repositories: repos, + diverId: diverId, + ); + + verify(mockDiveRepo.createDive(any)).called(1); + + final captured = verify( + mockTankPressureRepo.insertTankPressures(any, captureAny), + ).captured; + + final pressuresByTank = + captured.first + as Map>; + expect(pressuresByTank.keys, hasLength(2)); + expect(pressuresByTank.values.first, isNotEmpty); + expect(pressuresByTank.values.last, isNotEmpty); + }, + ); }); group('Progress callback', () { diff --git a/test/integration/uddf_round_trip_test.dart b/test/integration/uddf_round_trip_test.dart index da3dadf1e..d5ebe7ff4 100644 --- a/test/integration/uddf_round_trip_test.dart +++ b/test/integration/uddf_round_trip_test.dart @@ -399,5 +399,165 @@ void main() { reason: 'Total tank count should match through round-trip', ); }); + + test('imports multi-tank dive and stores separate pressure data', () async { + // 1. Manually create a UDDF string for a two-tank dive + const uddfContent = ''' + + + + + + 2024-01-01T12:00:00 + 1 + + + 12.0 + + + 11.0 + + + + 10 + 10.0 + 20000000 + 19000000 + + + 20 + 20.0 + 18000000 + 17000000 + + + + + + +'''; + + // 2. Import the data into the database + final importer = createImporter(); + await importer.importFromContent(uddfContent); + + // 3. Verify the dive was created + final diveRepository = DiveRepository(); + final dives = await diveRepository.getAllDives(); + expect(dives, hasLength(1)); + final diveId = dives.first.id; + + // 4. Query the tank pressure data from the repository + final tankPressureRepository = TankPressureRepository(); + final pressuresByTank = await tankPressureRepository + .getTankPressuresForDive(diveId); + + // 5. Assert that pressure data for two separate tanks was stored + expect( + pressuresByTank.keys, + hasLength(2), + reason: 'Should have pressure data for two tanks.', + ); + + final pressureSeries = pressuresByTank.values.toList() + ..sort((a, b) => a.first.pressure.compareTo(b.first.pressure)); + final pressuresLower = pressureSeries[0]; + final pressuresHigher = pressureSeries[1]; + + expect(pressuresLower, hasLength(2)); + expect(pressuresHigher, hasLength(2)); + expect(pressuresHigher.first.pressure, closeTo(200.0, 0.1)); + expect(pressuresLower.first.pressure, closeTo(190.0, 0.1)); + }); + + test( + 'imports multi-tank dive without tank IDs and stores separate pressure data', + () async { + // Test case for UDDF files where tankdata elements don't have id attributes + // but waypoints reference tanks as "T1", "T2", etc. (like Perdix AI exports) + const uddfContent = ''' + + + + + + 235 + 2025-09-01T14:18:24Z + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + 0 + 0 + + + 0 + 0 + + + + 1 + 0 + 20049962 + 21952916 + + + 3 + 10 + 19939646 + 21939126 + + + + + + +'''; + + // 2. Import the data into the database + final importer = createImporter(); + await importer.importFromContent(uddfContent); + + // 3. Verify the dive was created + final diveRepository = DiveRepository(); + final dives = await diveRepository.getAllDives(); + expect(dives, hasLength(1)); + final diveId = dives.first.id; + + // 4. Query the tank pressure data from the repository + final tankPressureRepository = TankPressureRepository(); + final pressuresByTank = await tankPressureRepository + .getTankPressuresForDive(diveId); + + // 5. Assert that pressure data for two separate tanks was stored + // (tanks 3-4 have zero pressures and should be filtered out) + expect( + pressuresByTank.keys, + hasLength(2), + reason: + 'Should have pressure data for two tanks with non-zero pressures.', + ); + + final pressureSeries = pressuresByTank.values.toList() + ..sort((a, b) => a.first.pressure.compareTo(b.first.pressure)); + final pressuresT1 = pressureSeries[0]; + final pressuresT2 = pressureSeries[1]; + + expect(pressuresT1, hasLength(2)); + expect(pressuresT2, hasLength(2)); + // First waypoint pressures + expect(pressuresT1.first.pressure, closeTo(200.5, 0.1)); + expect(pressuresT2.first.pressure, closeTo(219.5, 0.1)); + // Second waypoint pressures + expect(pressuresT1.last.pressure, closeTo(199.4, 0.1)); + expect(pressuresT2.last.pressure, closeTo(219.4, 0.1)); + }, + ); }); }