From fb222ec4e3c0962397f1c4a13aade2104fec8480 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:45:41 -0400 Subject: [PATCH 1/2] fix(import): read cylinder end pressure at surfacing (#1092) Dive computers keep recording after the diver reaches the surface. On a rebreather whose oxygen cylinder feeds a constant mass flow orifice, closing the valve topside leaves the hose bleeding down through it, and the tail of the recording sheds most of the cylinder's apparent contents. The reported dive read 41 bar of oxygen at 1.2 m and 4 bar two minutes later on the surface, so the import logged 4 bar. libdivecomputer builds DC_FIELD_TANK's end pressure by overwriting it with every pressure sample as it walks the log, with no surfacing check, so it lands on the last sample in the tail. The new rule finds the last sample deeper than 0.75 m (Subsurface's SURFACE_THRESHOLD) and reads each cylinder's most recent pressure at or before that moment. It corrects a reported end pressure only when that value matches the last post-surfacing reading, which proves the source simply took the last sample it had. A parser that reads end pressure from a log header, a transmitter that dropped out before surfacing, and an exporting app that computed its own value are all left alone. The correction only ever raises a value. Applied at two seams: resolveParsedTanks, shared by dive computer download and reparse, and a post-parse payload normalizer covering every file format at their common shape. Normalizing after the parsers run keeps FIT's cylinder-volume derivation on the figures Garmin reported. Governed by diver_settings.trim_tank_pressure_at_surfacing (schema v163, default on), surfaced in Settings > Data beneath Site Matching. --- lib/core/database/database.dart | 40 +++- lib/core/profile/surfacing_pressure.dart | 136 ++++++++++++ .../data/services/parsed_dive_mapper.dart | 11 +- .../data/services/parsed_tank_resolver.dart | 48 +++- .../data/services/reparse_service.dart | 13 +- .../providers/download_providers.dart | 21 +- .../providers/reparse_providers.dart | 11 +- .../diver_settings_repository.dart | 5 + .../presentation/pages/settings_page.dart | 14 ++ .../providers/settings_providers.dart | 13 ++ .../surfacing_pressure_normalizer.dart | 101 +++++++++ .../providers/universal_import_providers.dart | 22 +- lib/l10n/arb/app_ar.arb | 2 + lib/l10n/arb/app_de.arb | 2 + lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_es.arb | 2 + lib/l10n/arb/app_fr.arb | 2 + lib/l10n/arb/app_he.arb | 2 + lib/l10n/arb/app_hu.arb | 2 + lib/l10n/arb/app_it.arb | 2 + lib/l10n/arb/app_localizations.dart | 12 + lib/l10n/arb/app_localizations_ar.dart | 8 + lib/l10n/arb/app_localizations_de.dart | 8 + lib/l10n/arb/app_localizations_en.dart | 8 + lib/l10n/arb/app_localizations_es.dart | 8 + lib/l10n/arb/app_localizations_fr.dart | 8 + lib/l10n/arb/app_localizations_he.dart | 8 + lib/l10n/arb/app_localizations_hu.dart | 8 + lib/l10n/arb/app_localizations_it.dart | 8 + lib/l10n/arb/app_localizations_nl.dart | 8 + lib/l10n/arb/app_localizations_pt.dart | 8 + lib/l10n/arb/app_localizations_zh.dart | 7 + lib/l10n/arb/app_nl.arb | 2 + lib/l10n/arb/app_pt.arb | 2 + lib/l10n/arb/app_zh.arb | 2 + ...igration_v163_surfacing_pressure_test.dart | 70 ++++++ .../core/profile/surfacing_pressure_test.dart | 209 ++++++++++++++++++ .../services/parsed_dive_mapper_test.dart | 43 ++++ .../parsed_tank_resolver_surfacing_test.dart | 179 +++++++++++++++ .../reparse_service_surfacing_test.dart | 111 ++++++++++ .../download_notifier_surfacing_test.dart | 103 +++++++++ ...gs_repository_surfacing_pressure_test.dart | 55 +++++ .../pages/settings_page_shared_data_test.dart | 3 + ...settings_page_surfacing_pressure_test.dart | 71 ++++++ .../pages/settings_page_test.dart | 3 + .../presentation/pages/records_page_test.dart | 3 + .../surfacing_pressure_normalizer_test.dart | 190 ++++++++++++++++ ...versal_import_surfacing_pressure_test.dart | 156 +++++++++++++ test/helpers/mock_providers.dart | 3 + 49 files changed, 1738 insertions(+), 17 deletions(-) create mode 100644 lib/core/profile/surfacing_pressure.dart create mode 100644 lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart create mode 100644 test/core/database/migration_v163_surfacing_pressure_test.dart create mode 100644 test/core/profile/surfacing_pressure_test.dart create mode 100644 test/features/dive_computer/data/services/parsed_tank_resolver_surfacing_test.dart create mode 100644 test/features/dive_computer/data/services/reparse_service_surfacing_test.dart create mode 100644 test/features/dive_computer/presentation/providers/download_notifier_surfacing_test.dart create mode 100644 test/features/settings/data/repositories/diver_settings_repository_surfacing_pressure_test.dart create mode 100644 test/features/settings/presentation/pages/settings_page_surfacing_pressure_test.dart create mode 100644 test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart create mode 100644 test/features/universal_import/presentation/providers/universal_import_surfacing_pressure_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 92124f1a74..02cde83a7e 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1770,6 +1770,10 @@ class DiverSettings extends Table { // Auto site matching sensitivity (v76): strict | balanced | relaxed TextColumn get siteMatchSensitivity => text().withDefault(const Constant('balanced'))(); + // Read cylinder end pressure at surfacing rather than at the end of the + // recording (v163, issue #1092). + BoolColumn get trimTankPressureAtSurfacing => + boolean().withDefault(const Constant(true))(); // Dive profile chart defaults TextColumn get defaultRightAxisMetric => text().withDefault(const Constant('temperature'))(); @@ -3165,7 +3169,7 @@ class AppDatabase extends _$AppDatabase { /// The current schema version as a static constant so that pre-open checks /// (e.g. version-mismatch guard) can reference it without an instance. - static const int currentSchemaVersion = 161; + static const int currentSchemaVersion = 163; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3450,6 +3454,11 @@ class AppDatabase extends _$AppDatabase { // v161: diver_settings.default_show_o2_cell_mv, a persisted default for // the per-cell O2 mV toggle on the profile chart (issue #1235). 161, + // v163: diver_settings.trim_tank_pressure_at_surfacing, which decides + // whether an import reads cylinder end pressure at the moment of + // surfacing rather than at the end of the recording (issue #1092). + // v162 is claimed by the dive type badges branch (#1269). + 163, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4920,6 +4929,26 @@ class AppDatabase extends _$AppDatabase { } } + /// v163: trim_tank_pressure_at_surfacing on diver_settings (issue #1092). + /// Dive computers keep recording after the diver surfaces, so the last + /// pressure in the profile is not the pressure at the end of the dive. On + /// by default, because the reading it prefers can only ever be the higher, + /// earlier one. + Future _assertSurfacingPressureColumn() async { + final cols = await customSelect( + "PRAGMA table_info('diver_settings')", + ).get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('trim_tank_pressure_at_surfacing')) { + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN trim_tank_pressure_at_surfacing ' + 'INTEGER NOT NULL DEFAULT 1 ' + 'CHECK (trim_tank_pressure_at_surfacing IN (0, 1))', + ); + } + } + /// Default service price columns on service_kinds and service_schedules /// (issue #829). PRAGMA-guarded so a healthy database no-ops. The /// cols.isEmpty guard matters: minimal migration fixtures build databases @@ -8535,6 +8564,11 @@ class AppDatabase extends _$AppDatabase { await _assertO2CellMvDefaultColumn(); } if (from < 161) await reportProgress(); + // v163: trim_tank_pressure_at_surfacing on diver_settings (#1092). + if (from < 163) { + await _assertSurfacingPressureColumn(); + } + if (from < 163) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -8729,6 +8763,10 @@ class AppDatabase extends _$AppDatabase { // (issue #1235; same parallel-branch version-collision self-heal). await _assertO2CellMvDefaultColumn(); + // v163 backstop: re-assert diver_settings.trim_tank_pressure_at_ + // surfacing (issue #1092; same parallel-branch collision self-heal). + await _assertSurfacingPressureColumn(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); diff --git a/lib/core/profile/surfacing_pressure.dart b/lib/core/profile/surfacing_pressure.dart new file mode 100644 index 0000000000..8f5022f112 --- /dev/null +++ b/lib/core/profile/surfacing_pressure.dart @@ -0,0 +1,136 @@ +/// Depth below which a diver counts as being on the surface, in meters. +/// +/// Matches Subsurface's long-standing `SURFACE_THRESHOLD` of 750 mm, so a +/// dive imported from either app agrees on where the dive ended. +const double kSurfaceThresholdMeters = 0.75; + +/// How far a reported end pressure may sit from the last post-surfacing +/// reading and still count as having come from it, in bar. +/// +/// Sources quantize pressure before converting it: Shearwater logs units of +/// 2 psi (about 0.14 bar), and exported files often round to whole psi or bar. +/// The artifact this guards against is measured in tens of bar, so a tolerance +/// this small cannot let one through. +const double kPressureMatchToleranceBar = 0.5; + +/// One profile sample, reduced to what the surfacing rule needs: when it was +/// taken, how deep the diver was, and what each cylinder read at that instant. +/// +/// [tankPressuresBar] is keyed by cylinder index and is empty for a sample that +/// carries no transmitter reading. +class SurfacingProfilePoint { + const SurfacingProfilePoint({ + required this.timeSeconds, + required this.depthMeters, + this.tankPressuresBar = const {}, + }); + + final int timeSeconds; + final double depthMeters; + final Map tankPressuresBar; +} + +/// What one cylinder read on either side of the surfacing moment. +class SurfacingTankReading { + const SurfacingTankReading({ + required this.atSurfacing, + required this.lastAfterSurfacing, + }); + + /// The cylinder's most recent reading at or before surfacing: the pressure + /// it actually held at the end of the dive. + final double atSurfacing; + + /// The cylinder's last reading in the post-surfacing tail, or null when the + /// recording stopped at the surface. + final double? lastAfterSurfacing; +} + +/// What each cylinder read on either side of the moment the diver surfaced, +/// keyed by cylinder index. +/// +/// Dive computers keep recording for a while after the diver reaches the +/// surface, so the last reading in the profile is not the reading at the end of +/// the dive. On a rebreather whose oxygen cylinder feeds a constant mass flow +/// orifice, closing the valve topside leaves the hose bleeding down through +/// that orifice, and the tail of the recording can shed most of the cylinder's +/// apparent contents (issue #1092). +/// +/// Surfacing is the last sample deeper than [kSurfaceThresholdMeters], so a +/// diver who drops back down after a surface break is measured from the final +/// descent. Each cylinder is read independently and carries its most recent +/// value forward, because transmitters report on their own cadence and the +/// surfacing sample may hold no reading for a given cylinder. +/// +/// Cylinders with no reading at or before surfacing are left out: there is +/// nothing to correct a reported end pressure with. Returns an empty map when +/// the profile never went below the threshold or carries no pressure at all. +/// Sample order in [points] does not matter. +Map surfacingTankReadings( + List points, +) { + int? surfacingTime; + for (final p in points) { + if (p.depthMeters > kSurfaceThresholdMeters && + (surfacingTime == null || p.timeSeconds > surfacingTime)) { + surfacingTime = p.timeSeconds; + } + } + if (surfacingTime == null) { + return const {}; + } + + final atSurfacing = {}; + final atSurfacingTime = {}; + final afterSurfacing = {}; + final afterSurfacingTime = {}; + for (final p in points) { + final surfaced = p.timeSeconds > surfacingTime; + final values = surfaced ? afterSurfacing : atSurfacing; + final times = surfaced ? afterSurfacingTime : atSurfacingTime; + for (final entry in p.tankPressuresBar.entries) { + final seen = times[entry.key]; + if (seen == null || p.timeSeconds >= seen) { + values[entry.key] = entry.value; + times[entry.key] = p.timeSeconds; + } + } + } + + return { + for (final entry in atSurfacing.entries) + entry.key: SurfacingTankReading( + atSurfacing: entry.value, + lastAfterSurfacing: afterSurfacing[entry.key], + ), + }; +} + +/// The end pressure to record for a cylinder, given what the source reported +/// and what the profile read around surfacing. +/// +/// Only a reported pressure that matches the last post-surfacing reading is +/// corrected. That match is the evidence that the source simply took the last +/// sample it saw and so inherited the post-surfacing bleed-down. A source that +/// read its end pressure from anywhere else -- a log header, or a transmitter +/// that dropped out before the diver surfaced -- is left alone, because its +/// value has an origin this rule knows nothing about. +/// +/// Beyond that, the correction only ever raises [reportedBar], and a source +/// that reported nothing keeps reporting nothing rather than gaining a +/// fabricated value. +double? trimEndPressureBar({ + required double? reportedBar, + required SurfacingTankReading? reading, +}) { + if (reportedBar == null || reading == null) { + return reportedBar; + } + final tail = reading.lastAfterSurfacing; + if (tail == null || + (tail - reportedBar).abs() > kPressureMatchToleranceBar || + reading.atSurfacing <= reportedBar) { + return reportedBar; + } + return reading.atSurfacing; +} diff --git a/lib/features/dive_computer/data/services/parsed_dive_mapper.dart b/lib/features/dive_computer/data/services/parsed_dive_mapper.dart index a8bfce3952..cbab19f83e 100644 --- a/lib/features/dive_computer/data/services/parsed_dive_mapper.dart +++ b/lib/features/dive_computer/data/services/parsed_dive_mapper.dart @@ -5,7 +5,14 @@ import 'package:submersion/features/dive_computer/data/services/parsed_tank_reso import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; /// Convert a Pigeon ParsedDive to the app's DownloadedDive format. -DownloadedDive parsedDiveToDownloaded(pigeon.ParsedDive parsed) { +/// +/// [trimAtSurfacing] carries the diver's preference for reading cylinder end +/// pressure at the moment of surfacing rather than at the end of the recording +/// (issue #1092); see [resolveParsedTanks]. +DownloadedDive parsedDiveToDownloaded( + pigeon.ParsedDive parsed, { + bool trimAtSurfacing = true, +}) { // Some computers (e.g. Shearwater) don't provide top-level min/max // temperature — derive from profile samples when missing. final sampleTemps = parsed.samples @@ -85,7 +92,7 @@ DownloadedDive parsedDiveToDownloaded(pigeon.ParsedDive parsed) { .toList(), // Gas-mix linking, tankless synthesis, and gas-switch derivation live in // the shared resolver so the download and reparse paths cannot drift apart. - tanks: resolveParsedTanks(parsed), + tanks: resolveParsedTanks(parsed, trimAtSurfacing: trimAtSurfacing), gasSwitches: resolveGasSwitches(parsed), events: parsed.events .map( diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index 40b2c56a77..3d49722207 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -1,5 +1,6 @@ import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/profile/surfacing_pressure.dart'; import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; /// Resolve a parsed dive's gas mixes to concrete cylinders, shared by the @@ -12,8 +13,17 @@ import 'package:submersion/features/dive_computer/domain/entities/downloaded_div /// "first gas mix" fallback both mislabeled the transmitter and dropped any gas /// used without one (e.g. a deco bottle). Gases with no tank become pressureless /// cylinders. -List resolveParsedTanks(pigeon.ParsedDive parsed) => - _resolveCylinders(parsed).tanks; +/// +/// When [trimAtSurfacing] is set, each cylinder's end pressure is read at the +/// moment the diver surfaced rather than at the end of the recording. Dive +/// computers keep logging topside, and a rebreather oxygen cylinder feeding a +/// constant mass flow orifice bleeds down through it once the valve is closed, +/// so the computer's own end pressure can be a small fraction of what was +/// actually left at the end of the dive (issue #1092). +List resolveParsedTanks( + pigeon.ParsedDive parsed, { + bool trimAtSurfacing = true, +}) => _resolveCylinders(parsed, trimAtSurfacing: trimAtSurfacing).tanks; /// Derive the dive's gas switches from per-sample gas-mix transitions, keyed by /// the cylinder index assigned by [resolveParsedTanks]. @@ -26,7 +36,10 @@ List resolveParsedTanks(pigeon.ParsedDive parsed) => /// a different gas mix becomes a [GasSwitchEvent] pointing at the cylinder that /// holds that gas. List resolveGasSwitches(pigeon.ParsedDive parsed) { - final gasIndexToTankIndex = _resolveCylinders(parsed).gasIndexToTankIndex; + final gasIndexToTankIndex = _resolveCylinders( + parsed, + trimAtSurfacing: false, + ).gasIndexToTankIndex; if (gasIndexToTankIndex.isEmpty) { return const []; } @@ -81,7 +94,10 @@ class _ResolvedCylinders { const _ResolvedCylinders(this.tanks, this.gasIndexToTankIndex); } -_ResolvedCylinders _resolveCylinders(pigeon.ParsedDive parsed) { +_ResolvedCylinders _resolveCylinders( + pigeon.ParsedDive parsed, { + required bool trimAtSurfacing, +}) { final gasMixes = parsed.gasMixes; final gasIndexToTankIndex = {}; @@ -111,6 +127,11 @@ _ResolvedCylinders _resolveCylinders(pigeon.ParsedDive parsed) { } // Gas indices are positions into gasMixes (every bridge sets GasMix.index == i). + // Scanned only once there are tank records to correct: a tankless dive + // synthesizes pressureless cylinders that have no end pressure to trim. + final surfacingReadings = trimAtSurfacing + ? surfacingTankReadings(_surfacingPoints(parsed.samples)) + : const {}; final result = []; final consumed = {}; @@ -130,7 +151,10 @@ _ResolvedCylinders _resolveCylinders(pigeon.ParsedDive parsed) { o2Percent: o2, hePercent: he, startPressure: tank.startPressureBar, - endPressure: tank.endPressureBar, + endPressure: trimEndPressureBar( + reportedBar: tank.endPressureBar, + reading: surfacingReadings[tank.index], + ), volumeLiters: tank.volumeLiters, role: _inferRole(tank.usage, o2, he), ), @@ -248,3 +272,17 @@ int _firstFreeIndex(pigeon.ParsedDive parsed) { } return maxIndex + 1; } + +/// Reduce libdivecomputer samples to the depth-plus-pressure points the +/// surfacing rule reads. A sample carries at most one transmitter reading, so +/// each point holds either one entry or none. +List _surfacingPoints(List s) => [ + for (final sample in s) + SurfacingProfilePoint( + timeSeconds: sample.timeSeconds, + depthMeters: sample.depthMeters, + tankPressuresBar: sample.pressureBar != null && sample.tankIndex != null + ? {sample.tankIndex!: sample.pressureBar!} + : const {}, + ), +]; diff --git a/lib/features/dive_computer/data/services/reparse_service.dart b/lib/features/dive_computer/data/services/reparse_service.dart index 017142e8af..49e181d14e 100644 --- a/lib/features/dive_computer/data/services/reparse_service.dart +++ b/lib/features/dive_computer/data/services/reparse_service.dart @@ -17,7 +17,13 @@ class ReparseService { final AppDatabase db; final _uuid = const Uuid(); - ReparseService({required this.db}); + /// The diver's preference for reading cylinder end pressure at the moment of + /// surfacing rather than at the end of the recording (issue #1092). Reparse + /// is how an already-imported dive picks the rule up, so it has to agree + /// with the live download path. + final bool trimTankPressureAtSurfacing; + + ReparseService({required this.db, this.trimTankPressureAtSurfacing = true}); /// Apply a freshly parsed dive to the database, updating only /// computer-authored fields and preserving user-authored fields. @@ -671,7 +677,10 @@ class ReparseService { // Gas-mix linking and tankless synthesis (computers that report gas // mixes but no tank records) live in the shared resolver so this path // cannot drift from the live-download mapper. - for (final tank in resolveParsedTanks(parsed)) { + for (final tank in resolveParsedTanks( + parsed, + trimAtSurfacing: trimTankPressureAtSurfacing, + )) { newTankOrders.add(tank.index); final existing = existingByOrder[tank.index]; diff --git a/lib/features/dive_computer/presentation/providers/download_providers.dart b/lib/features/dive_computer/presentation/providers/download_providers.dart index 9665f45ad2..832c08a54b 100644 --- a/lib/features/dive_computer/presentation/providers/download_providers.dart +++ b/lib/features/dive_computer/presentation/providers/download_providers.dart @@ -17,6 +17,7 @@ import 'package:submersion/features/dive_computer/domain/services/first_sync_cut import 'package:submersion/features/dive_computer/presentation/providers/discovery_providers.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/gps_log/presentation/providers/gps_log_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; /// Provider for the dive computer repository. final diveComputerRepositoryProvider = Provider((ref) { @@ -130,11 +131,19 @@ class DownloadNotifier extends StateNotifier { // Stored for device info persistence after download completes. DiveComputer? _computer; + /// Reads the diver's surfacing-pressure preference at the moment a dive + /// arrives (issue #1092). A getter rather than a value so a settings change + /// never has to tear down a notifier with a download in flight; null means + /// the default, on. + final bool Function()? _trimTankPressureAtSurfacing; + DownloadNotifier({ required pigeon.DiveComputerService service, required DiveComputerRepository repository, + bool Function()? trimTankPressureAtSurfacing, }) : _service = service, _repository = repository, + _trimTankPressureAtSurfacing = trimTankPressureAtSurfacing, super(const DownloadState()); /// Set whether to download new dives only. @@ -225,7 +234,10 @@ class DownloadNotifier extends StateNotifier { case pigeon.PinCodeRequestEvent(): state = state.copyWith(phase: DownloadPhase.pinRequired); case pigeon.DiveDownloadedEvent(:final dive): - final downloaded = parsedDiveToDownloaded(dive); + final downloaded = parsedDiveToDownloaded( + dive, + trimAtSurfacing: _trimTankPressureAtSurfacing?.call() ?? true, + ); state = state.copyWith( downloadedDives: [...state.downloadedDives, downloaded], ); @@ -318,7 +330,12 @@ final downloadNotifierProvider = final service = ref.watch(diveComputerServiceProvider); final repository = ref.watch(diveComputerRepositoryProvider); - return DownloadNotifier(service: service, repository: repository); + return DownloadNotifier( + service: service, + repository: repository, + trimTankPressureAtSurfacing: () => + ref.read(settingsProvider).trimTankPressureAtSurfacing, + ); }); /// Provider for checking if a download is in progress. diff --git a/lib/features/dive_computer/presentation/providers/reparse_providers.dart b/lib/features/dive_computer/presentation/providers/reparse_providers.dart index 34350f6233..f94f003781 100644 --- a/lib/features/dive_computer/presentation/providers/reparse_providers.dart +++ b/lib/features/dive_computer/presentation/providers/reparse_providers.dart @@ -4,11 +4,20 @@ import 'package:submersion/core/providers/ref_invalidate_on_change.dart'; import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/features/dive_computer/data/services/reparse_service.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; /// Provider for the [ReparseService] singleton. +/// +/// Watches the surfacing-pressure setting so a reparse run after the diver +/// flips it applies the current preference (issue #1092). final reparseServiceProvider = Provider((ref) { final db = DatabaseService.instance.database; - return ReparseService(db: db); + return ReparseService( + db: db, + trimTankPressureAtSurfacing: ref.watch( + settingsProvider.select((s) => s.trimTankPressureAtSurfacing), + ), + ); }); /// Provides raw data counts for all dive computer sources matching [computerId]. diff --git a/lib/features/settings/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index d8ee465a97..3f3667cd25 100644 --- a/lib/features/settings/data/repositories/diver_settings_repository.dart +++ b/lib/features/settings/data/repositories/diver_settings_repository.dart @@ -143,6 +143,7 @@ class DiverSettingsRepository { diveCenterListViewMode: Value(s.diveCenterListViewMode.name), mapStyle: Value(s.mapStyle.name), siteMatchSensitivity: Value(s.siteMatchSensitivity.name), + trimTankPressureAtSurfacing: Value(s.trimTankPressureAtSurfacing), cardColorGradientPreset: Value(s.cardColorGradientPreset), cardColorGradientStart: Value(s.cardColorGradientStart), cardColorGradientEnd: Value(s.cardColorGradientEnd), @@ -305,6 +306,9 @@ class DiverSettingsRepository { diveCenterListViewMode: Value(settings.diveCenterListViewMode.name), mapStyle: Value(settings.mapStyle.name), siteMatchSensitivity: Value(settings.siteMatchSensitivity.name), + trimTankPressureAtSurfacing: Value( + settings.trimTankPressureAtSurfacing, + ), cardColorGradientPreset: Value(settings.cardColorGradientPreset), cardColorGradientStart: Value(settings.cardColorGradientStart), cardColorGradientEnd: Value(settings.cardColorGradientEnd), @@ -515,6 +519,7 @@ class DiverSettingsRepository { siteMatchSensitivity: SiteMatchSensitivity.fromName( row.siteMatchSensitivity, ), + trimTankPressureAtSurfacing: row.trimTankPressureAtSurfacing, cardColorGradientPreset: row.cardColorGradientPreset, cardColorGradientStart: row.cardColorGradientStart, cardColorGradientEnd: row.cardColorGradientEnd, diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 0b722cb84f..dcd0016d1c 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -2566,6 +2566,20 @@ class _DataSectionContent extends ConsumerWidget { ), ), ), + const SizedBox(height: 8), + Card( + child: SwitchListTile( + secondary: const Icon(Icons.compress), + title: Text(context.l10n.settings_tankPressureAtSurfacing_title), + subtitle: Text( + context.l10n.settings_tankPressureAtSurfacing_subtitle, + ), + value: ref.watch(settingsProvider).trimTankPressureAtSurfacing, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setTrimTankPressureAtSurfacing(value), + ), + ), const SizedBox(height: 16), _buildSectionHeader( context, diff --git a/lib/features/settings/presentation/providers/settings_providers.dart b/lib/features/settings/presentation/providers/settings_providers.dart index a0b2d2f5d5..2735c2270c 100644 --- a/lib/features/settings/presentation/providers/settings_providers.dart +++ b/lib/features/settings/presentation/providers/settings_providers.dart @@ -301,6 +301,10 @@ class AppSettings { /// How aggressively downloaded dives are auto-matched to sites. final SiteMatchSensitivity siteMatchSensitivity; + /// Whether an import reads cylinder end pressure at the moment of surfacing + /// rather than at the end of the recording (issue #1092). + final bool trimTankPressureAtSurfacing; + /// Name of the selected gradient preset ('ocean', 'thermal', etc.) final String cardColorGradientPreset; @@ -530,6 +534,7 @@ class AppSettings { this.diveCenterListViewMode = ListViewMode.detailed, this.mapStyle = MapStyle.openStreetMap, this.siteMatchSensitivity = SiteMatchSensitivity.balanced, + this.trimTankPressureAtSurfacing = true, this.cardColorGradientPreset = 'ocean', this.cardColorGradientStart, this.cardColorGradientEnd, @@ -690,6 +695,7 @@ class AppSettings { ListViewMode? diveCenterListViewMode, MapStyle? mapStyle, SiteMatchSensitivity? siteMatchSensitivity, + bool? trimTankPressureAtSurfacing, String? cardColorGradientPreset, int? cardColorGradientStart, int? cardColorGradientEnd, @@ -827,6 +833,8 @@ class AppSettings { diveCenterListViewMode ?? this.diveCenterListViewMode, mapStyle: mapStyle ?? this.mapStyle, siteMatchSensitivity: siteMatchSensitivity ?? this.siteMatchSensitivity, + trimTankPressureAtSurfacing: + trimTankPressureAtSurfacing ?? this.trimTankPressureAtSurfacing, cardColorGradientPreset: cardColorGradientPreset ?? this.cardColorGradientPreset, cardColorGradientStart: clearCardColorGradientStart @@ -1644,6 +1652,11 @@ class SettingsNotifier extends StateNotifier { await _saveSettings(); } + Future setTrimTankPressureAtSurfacing(bool value) async { + state = state.copyWith(trimTankPressureAtSurfacing: value); + await _saveSettings(); + } + Future setCardColorGradientPreset(String preset) async { state = state.copyWith( cardColorGradientPreset: preset, diff --git a/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart b/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart new file mode 100644 index 0000000000..cba9d4fc78 --- /dev/null +++ b/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart @@ -0,0 +1,101 @@ +import 'package:submersion/core/profile/surfacing_pressure.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; + +/// Rewrite every dive's cylinder end pressures to the reading taken at the +/// moment the diver surfaced, wherever the payload shows the source simply +/// took the last sample it had (issue #1092). +/// +/// Applied once after parsing, so it covers every format at their common +/// payload shape rather than each parser separately. Two details matter here: +/// +/// * `allTankPressures.tankIndex` indexes the dive's `tanks` list by position, +/// which is how `UddfEntityImporter` resolves it. It is not the tank's +/// `order`. +/// * FIT derives a Garmin cylinder's volume from its own start, end and +/// volume-used figures. Normalizing after the parser has run keeps that +/// derivation on the numbers Garmin reported. +/// +/// The payload is rebuilt rather than edited: nothing the caller passed in is +/// mutated. A dive with no profile, no tanks, or no end pressure that matches +/// the post-surfacing tail comes through untouched. +ImportPayload trimTankPressuresAtSurfacing(ImportPayload payload) { + final dives = payload.entitiesOf(ImportEntityType.dives); + if (dives.isEmpty) { + return payload; + } + + return ImportPayload( + entities: { + ...payload.entities, + ImportEntityType.dives: [for (final dive in dives) _trimDive(dive)], + }, + warnings: payload.warnings, + metadata: payload.metadata, + ); +} + +Map _trimDive(Map dive) { + final tanks = dive['tanks']; + final profile = dive['profile']; + if (tanks is! List || tanks.isEmpty || profile is! List || profile.isEmpty) { + return dive; + } + + final readings = surfacingTankReadings(_points(profile)); + if (readings.isEmpty) { + return dive; + } + + var changed = false; + final trimmed = >[]; + for (var i = 0; i < tanks.length; i++) { + final tank = tanks[i]; + if (tank is! Map) { + return dive; + } + final reported = (tank['endPressure'] as num?)?.toDouble(); + final end = trimEndPressureBar(reportedBar: reported, reading: readings[i]); + if (end == reported) { + trimmed.add(tank); + continue; + } + changed = true; + trimmed.add({...tank, 'endPressure': end}); + } + + return changed ? {...dive, 'tanks': trimmed} : dive; +} + +/// Reduce payload profile points to what the surfacing rule reads. A point +/// without a depth cannot place the surfacing moment, so it is skipped. +List _points(List profile) { + final points = []; + for (final raw in profile) { + if (raw is! Map) continue; + final depth = (raw['depth'] as num?)?.toDouble(); + if (depth == null) continue; + + final pressures = {}; + final all = raw['allTankPressures']; + if (all is List) { + for (final entry in all) { + if (entry is! Map) continue; + final index = (entry['tankIndex'] as num?)?.toInt(); + final pressure = (entry['pressure'] as num?)?.toDouble(); + if (index != null && pressure != null) { + pressures[index] = pressure; + } + } + } + + points.add( + SurfacingProfilePoint( + timeSeconds: (raw['timestamp'] as num?)?.toInt() ?? 0, + depthMeters: depth, + tankPressuresBar: pressures, + ), + ); + } + return points; +} 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 bfadda116e..428ba60e36 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -37,6 +37,7 @@ import 'package:submersion/features/universal_import/data/services/garmin_device import 'package:submersion/features/universal_import/data/services/macdive_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/payload_merger.dart'; import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/surfacing_pressure_normalizer.dart'; import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; import 'package:submersion/features/universal_import/data/services/zip_expansion_service.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_state.dart'; @@ -734,7 +735,9 @@ class UniversalImportNotifier extends StateNotifier { return; } - final payload = const PayloadMerger().merge(result.parsed); + final payload = _applySurfacingPressureRule( + const PayloadMerger().merge(result.parsed), + ); final dupResult = await _checkDuplicates(payload); final selections = _defaultSelections(payload, dupResult); @@ -762,17 +765,18 @@ class UniversalImportNotifier extends StateNotifier { ? await _buildPresetRegistry() : null; final parser = _parserFor(opts.format, registry: registry); - final ImportPayload payload; + final ImportPayload parsed; if (parser is CsvImportParser) { - payload = await parser.parse( + parsed = await parser.parse( bytes, options: opts, customMappingOverride: state.fieldMapping, profileFileBytes: state.additionalFileBytes, ); } else { - payload = await parser.parse(bytes, options: opts); + parsed = await parser.parse(bytes, options: opts); } + final payload = _applySurfacingPressureRule(parsed); if (payload.isEmpty) { final errorMsg = payload.warnings.isNotEmpty @@ -837,6 +841,16 @@ class UniversalImportNotifier extends StateNotifier { return selections; } + /// Read cylinder end pressure at the moment of surfacing rather than at the + /// end of the recording, when the diver has that on (issue #1092). Applied + /// to the finished payload so every format is covered at one seam, and after + /// the parsers have run so a parser that derives other figures from the + /// source's own start/end pair (FIT cylinder volume) still sees them. + ImportPayload _applySurfacingPressureRule(ImportPayload payload) { + final trim = _ref.read(settingsProvider).trimTankPressureAtSurfacing; + return trim ? trimTankPressuresAtSurfacing(payload) : payload; + } + Future _checkDuplicates(ImportPayload payload) async { const checker = ImportDuplicateChecker(); diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..6f6126a79a 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -6087,6 +6087,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "مطابقة المواقع تلقائيًا", "settings_siteMatch_subtitle": "مدى صرامة مطابقة الغوصات التي تم تنزيلها بالمواقع", + "settings_tankPressureAtSurfacing_title": "ضغط الأسطوانة عند الصعود إلى السطح", + "settings_tankPressureAtSurfacing_subtitle": "قراءة ضغط النهاية عند الوصول إلى السطح، وليس عند انتهاء التسجيل", "settings_siteMatch_strict": "صارم", "settings_siteMatch_balanced": "متوازن", "settings_siteMatch_relaxed": "متساهل", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..808f1c98c8 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -6087,6 +6087,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "Automatische Tauchplatzzuordnung", "settings_siteMatch_subtitle": "Wie aggressiv heruntergeladene Tauchgänge Tauchplätzen zugeordnet werden", + "settings_tankPressureAtSurfacing_title": "Flaschendruck beim Auftauchen", + "settings_tankPressureAtSurfacing_subtitle": "Enddruck beim Erreichen der Oberfläche übernehmen, nicht am Ende der Aufzeichnung", "settings_siteMatch_strict": "Streng", "settings_siteMatch_balanced": "Ausgewogen", "settings_siteMatch_relaxed": "Locker", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..e636b7ef03 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -14127,6 +14127,8 @@ "settings_section_dataSources_subtitle": "Health data integration", "settings_siteMatch_title": "Auto site matching", "settings_siteMatch_subtitle": "How aggressively downloaded dives are matched to sites", + "settings_tankPressureAtSurfacing_title": "Tank pressure at surfacing", + "settings_tankPressureAtSurfacing_subtitle": "Read end pressure when you reached the surface, not when the computer stopped recording", "settings_siteMatch_strict": "Strict", "settings_siteMatch_balanced": "Balanced", "settings_siteMatch_relaxed": "Relaxed", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..4cbcf7f484 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -6087,6 +6087,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "Asociación automática de puntos", "settings_siteMatch_subtitle": "Con qué intensidad se asocian a puntos de buceo las inmersiones descargadas", + "settings_tankPressureAtSurfacing_title": "Presión de la botella al salir a superficie", + "settings_tankPressureAtSurfacing_subtitle": "Tomar la presión final al llegar a la superficie, no al terminar el registro", "settings_siteMatch_strict": "Estricto", "settings_siteMatch_balanced": "Equilibrado", "settings_siteMatch_relaxed": "Relajado", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..f774a7b9ab 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -6014,6 +6014,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "Association automatique des sites", "settings_siteMatch_subtitle": "À quel point les plongées téléchargées sont associées aux sites", + "settings_tankPressureAtSurfacing_title": "Pression du bloc à l'arrivée en surface", + "settings_tankPressureAtSurfacing_subtitle": "Relever la pression finale à l'arrivée en surface, et non à la fin de l'enregistrement", "settings_siteMatch_strict": "Strict", "settings_siteMatch_balanced": "Équilibré", "settings_siteMatch_relaxed": "Souple", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..3ff14429f3 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -6087,6 +6087,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "התאמת אתרים אוטומטית", "settings_siteMatch_subtitle": "באיזו מידה צלילות שהורדו מותאמות לאתרים", + "settings_tankPressureAtSurfacing_title": "לחץ המיכל בעלייה לפני השטח", + "settings_tankPressureAtSurfacing_subtitle": "קריאת לחץ הסיום ברגע ההגעה לפני השטח, ולא בסוף ההקלטה", "settings_siteMatch_strict": "קפדני", "settings_siteMatch_balanced": "מאוזן", "settings_siteMatch_relaxed": "גמיש", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..19eba22bf1 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -6014,6 +6014,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "Automatikus helyhozzárendelés", "settings_siteMatch_subtitle": "Mennyire agresszíven rendelődnek a letöltött merülések a helyekhez", + "settings_tankPressureAtSurfacing_title": "Palacknyomás felbukkanáskor", + "settings_tankPressureAtSurfacing_subtitle": "A végnyomás a felszínre érkezéskor legyen leolvasva, ne a rögzítés végén", "settings_siteMatch_strict": "Szigorú", "settings_siteMatch_balanced": "Kiegyensúlyozott", "settings_siteMatch_relaxed": "Laza", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..a504fa7339 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -6010,6 +6010,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "Associazione automatica dei siti", "settings_siteMatch_subtitle": "Con quanta intensità le immersioni scaricate vengono associate ai siti", + "settings_tankPressureAtSurfacing_title": "Pressione della bombola in superficie", + "settings_tankPressureAtSurfacing_subtitle": "Rileva la pressione finale quando raggiungi la superficie, non a fine registrazione", "settings_siteMatch_strict": "Rigoroso", "settings_siteMatch_balanced": "Bilanciato", "settings_siteMatch_relaxed": "Rilassato", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..fc1b5884fd 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -36569,6 +36569,18 @@ abstract class AppLocalizations { /// **'How aggressively downloaded dives are matched to sites'** String get settings_siteMatch_subtitle; + /// No description provided for @settings_tankPressureAtSurfacing_title. + /// + /// In en, this message translates to: + /// **'Tank pressure at surfacing'** + String get settings_tankPressureAtSurfacing_title; + + /// No description provided for @settings_tankPressureAtSurfacing_subtitle. + /// + /// In en, this message translates to: + /// **'Read end pressure when you reached the surface, not when the computer stopped recording'** + String get settings_tankPressureAtSurfacing_subtitle; + /// No description provided for @settings_siteMatch_strict. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 5e2d05392f..70a0abfa48 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -21625,6 +21625,14 @@ class AppLocalizationsAr extends AppLocalizations { String get settings_siteMatch_subtitle => 'مدى صرامة مطابقة الغوصات التي تم تنزيلها بالمواقع'; + @override + String get settings_tankPressureAtSurfacing_title => + 'ضغط الأسطوانة عند الصعود إلى السطح'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'قراءة ضغط النهاية عند الوصول إلى السطح، وليس عند انتهاء التسجيل'; + @override String get settings_siteMatch_strict => 'صارم'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..6903994cf8 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -21974,6 +21974,14 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_siteMatch_subtitle => 'Wie aggressiv heruntergeladene Tauchgänge Tauchplätzen zugeordnet werden'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Flaschendruck beim Auftauchen'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'Enddruck beim Erreichen der Oberfläche übernehmen, nicht am Ende der Aufzeichnung'; + @override String get settings_siteMatch_strict => 'Streng'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..6abc9e5701 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -21641,6 +21641,14 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_siteMatch_subtitle => 'How aggressively downloaded dives are matched to sites'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Tank pressure at surfacing'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'Read end pressure when you reached the surface, not when the computer stopped recording'; + @override String get settings_siteMatch_strict => 'Strict'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..6fd928e64a 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -22026,6 +22026,14 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_siteMatch_subtitle => 'Con qué intensidad se asocian a puntos de buceo las inmersiones descargadas'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Presión de la botella al salir a superficie'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'Tomar la presión final al llegar a la superficie, no al terminar el registro'; + @override String get settings_siteMatch_strict => 'Estricto'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..22f3ffe47b 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -22083,6 +22083,14 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_siteMatch_subtitle => 'À quel point les plongées téléchargées sont associées aux sites'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Pression du bloc à l\'arrivée en surface'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'Relever la pression finale à l\'arrivée en surface, et non à la fin de l\'enregistrement'; + @override String get settings_siteMatch_strict => 'Strict'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..1ac1a3f8f9 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -21467,6 +21467,14 @@ class AppLocalizationsHe extends AppLocalizations { String get settings_siteMatch_subtitle => 'באיזו מידה צלילות שהורדו מותאמות לאתרים'; + @override + String get settings_tankPressureAtSurfacing_title => + 'לחץ המיכל בעלייה לפני השטח'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'קריאת לחץ הסיום ברגע ההגעה לפני השטח, ולא בסוף ההקלטה'; + @override String get settings_siteMatch_strict => 'קפדני'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..11ac1ed81c 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -21940,6 +21940,14 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_siteMatch_subtitle => 'Mennyire agresszíven rendelődnek a letöltött merülések a helyekhez'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Palacknyomás felbukkanáskor'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'A végnyomás a felszínre érkezéskor legyen leolvasva, ne a rögzítés végén'; + @override String get settings_siteMatch_strict => 'Szigorú'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..d1a276f99c 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -22009,6 +22009,14 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_siteMatch_subtitle => 'Con quanta intensità le immersioni scaricate vengono associate ai siti'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Pressione della bombola in superficie'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'Rileva la pressione finale quando raggiungi la superficie, non a fine registrazione'; + @override String get settings_siteMatch_strict => 'Rigoroso'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..c5d57141f3 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -21842,6 +21842,14 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_siteMatch_subtitle => 'Hoe agressief gedownloade duiken aan stekken worden gekoppeld'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Flesdruk bij bovenkomen'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'Neem de einddruk op het moment van bovenkomen, niet aan het einde van de opname'; + @override String get settings_siteMatch_strict => 'Strikt'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..facadee487 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -22011,6 +22011,14 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_siteMatch_subtitle => 'Com que intensidade os mergulhos baixados são associados aos pontos'; + @override + String get settings_tankPressureAtSurfacing_title => + 'Pressão da garrafa à superfície'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + 'Usar a pressão final ao chegar à superfície, não no fim da gravação'; + @override String get settings_siteMatch_strict => 'Rigoroso'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..2b28414c8c 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -20905,6 +20905,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_siteMatch_subtitle => '下载的潜水与潜水点匹配的积极程度'; + @override + String get settings_tankPressureAtSurfacing_title => '出水时的气瓶压力'; + + @override + String get settings_tankPressureAtSurfacing_subtitle => + '以到达水面时的压力作为结束压力,而不是记录结束时的压力'; + @override String get settings_siteMatch_strict => '严格'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..90397f65b8 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -6087,6 +6087,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "Automatisch stekken koppelen", "settings_siteMatch_subtitle": "Hoe agressief gedownloade duiken aan stekken worden gekoppeld", + "settings_tankPressureAtSurfacing_title": "Flesdruk bij bovenkomen", + "settings_tankPressureAtSurfacing_subtitle": "Neem de einddruk op het moment van bovenkomen, niet aan het einde van de opname", "settings_siteMatch_strict": "Strikt", "settings_siteMatch_balanced": "Gebalanceerd", "settings_siteMatch_relaxed": "Soepel", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..dc2cd79aa4 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -6087,6 +6087,8 @@ "settings_section_dataSources_subtitle": "Connected services & integrations", "settings_siteMatch_title": "Associação automática de pontos", "settings_siteMatch_subtitle": "Com que intensidade os mergulhos baixados são associados aos pontos", + "settings_tankPressureAtSurfacing_title": "Pressão da garrafa à superfície", + "settings_tankPressureAtSurfacing_subtitle": "Usar a pressão final ao chegar à superfície, não no fim da gravação", "settings_siteMatch_strict": "Rigoroso", "settings_siteMatch_balanced": "Equilibrado", "settings_siteMatch_relaxed": "Flexível", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..de828bbb2d 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -5025,6 +5025,8 @@ "settings_section_dataSources_subtitle": "健康数据集成", "settings_siteMatch_title": "自动匹配潜水点", "settings_siteMatch_subtitle": "下载的潜水与潜水点匹配的积极程度", + "settings_tankPressureAtSurfacing_title": "出水时的气瓶压力", + "settings_tankPressureAtSurfacing_subtitle": "以到达水面时的压力作为结束压力,而不是记录结束时的压力", "settings_siteMatch_strict": "严格", "settings_siteMatch_balanced": "平衡", "settings_siteMatch_relaxed": "宽松", diff --git a/test/core/database/migration_v163_surfacing_pressure_test.dart b/test/core/database/migration_v163_surfacing_pressure_test.dart new file mode 100644 index 0000000000..c2528143b7 --- /dev/null +++ b/test/core/database/migration_v163_surfacing_pressure_test.dart @@ -0,0 +1,70 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v163 shape: a diver_settings table without the surfacing +/// pressure column, stamped at v161 so the upgrade to 163 runs. +NativeDatabase _dbAt161() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 161'); + rawDb.execute(''' + CREATE TABLE diver_settings ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + rawDb.execute("INSERT INTO diver_settings (id) VALUES ('settings')"); + }, + ); +} + +void main() { + test('v163 adds trim_tank_pressure_at_surfacing defaulting to 1', () async { + final db = AppDatabase(_dbAt161()); + addTearDown(() => db.close()); + + final cols = await db + .customSelect("PRAGMA table_info('diver_settings')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('trim_tank_pressure_at_surfacing')); + + // Existing divers opt in, because the reading the rule prefers can only + // ever be the higher, earlier one (issue #1092). + final row = await db + .customSelect( + 'SELECT trim_tank_pressure_at_surfacing FROM ' + 'diver_settings', + ) + .getSingle(); + expect(row.read('trim_tank_pressure_at_surfacing'), 1); + }); + + test( + 'fresh databases get the trim_tank_pressure_at_surfacing column', + () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + final cols = await db + .customSelect("PRAGMA table_info('diver_settings')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('trim_tank_pressure_at_surfacing')); + }, + ); + + test('the helper no-ops when diver_settings is absent', () async { + final native = NativeDatabase.memory( + setup: (rawDb) => rawDb.execute('PRAGMA user_version = 161'), + ); + final db = AppDatabase(native); + addTearDown(db.close); + + await expectLater(db.customSelect('SELECT 1').get(), completes); + }); + + test('v163 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(163)); + expect(AppDatabase.migrationVersions, contains(163)); + }); +} diff --git a/test/core/profile/surfacing_pressure_test.dart b/test/core/profile/surfacing_pressure_test.dart new file mode 100644 index 0000000000..8983f809fc --- /dev/null +++ b/test/core/profile/surfacing_pressure_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/profile/surfacing_pressure.dart'; + +void main() { + SurfacingProfilePoint point( + int timeSeconds, + double depthMeters, [ + Map pressures = const {}, + ]) { + return SurfacingProfilePoint( + timeSeconds: timeSeconds, + depthMeters: depthMeters, + tankPressuresBar: pressures, + ); + } + + group('surfacingTankReadings', () { + test('splits each cylinder into its surfacing and post-surfacing ' + 'readings', () { + // Issue #1092: a CCR oxygen cylinder bleeds down through the constant + // mass flow orifice while the computer keeps recording on the surface. + final points = [ + point(0, 0.0, {1: 200.0}), + point(600, 40.0, {1: 120.0}), + point(3970, 1.2, {1: 41.0}), + point(4000, 0.0, {1: 30.0}), + point(4090, 0.0, {1: 4.0}), + ]; + + final readings = surfacingTankReadings(points); + + expect(readings[1]!.atSurfacing, 41.0); + expect(readings[1]!.lastAfterSurfacing, 4.0); + }); + + test('reads every cylinder independently', () { + final points = [ + point(0, 5.0, {0: 200.0, 1: 180.0}), + point(600, 2.0, {0: 150.0, 1: 41.0}), + point(900, 0.0, {0: 149.0, 1: 4.0}), + ]; + + final readings = surfacingTankReadings(points); + + expect(readings[0]!.atSurfacing, 150.0); + expect(readings[0]!.lastAfterSurfacing, 149.0); + expect(readings[1]!.atSurfacing, 41.0); + expect(readings[1]!.lastAfterSurfacing, 4.0); + }); + + test('carries a cylinder forward when the surfacing sample omits it', () { + // Transmitters report on their own cadence, so the sample that happens to + // be the deepest-last one may carry no reading for a given cylinder. + final points = [ + point(0, 20.0, {0: 200.0, 1: 180.0}), + point(600, 10.0, {1: 150.0}), + point(900, 3.0, {0: 120.0}), + point(1200, 0.0, {0: 60.0, 1: 20.0}), + ]; + + final readings = surfacingTankReadings(points); + + expect(readings[0]!.atSurfacing, 120.0); + expect(readings[1]!.atSurfacing, 150.0); + }); + + test('reports no tail reading when the recording ends at surfacing', () { + final points = [ + point(0, 20.0, {0: 200.0}), + point(600, 10.0, {0: 120.0}), + point(900, 3.0, {0: 60.0}), + ]; + + final readings = surfacingTankReadings(points); + + expect(readings[0]!.atSurfacing, 60.0); + expect(readings[0]!.lastAfterSurfacing, isNull); + }); + + test('uses the final descent when the diver re-descends', () { + final points = [ + point(0, 20.0, {0: 200.0}), + point(300, 0.0, {0: 160.0}), + point(600, 15.0, {0: 140.0}), + point(900, 5.0, {0: 100.0}), + point(1200, 0.0, {0: 90.0}), + ]; + + final readings = surfacingTankReadings(points); + + expect(readings[0]!.atSurfacing, 100.0); + expect(readings[0]!.lastAfterSurfacing, 90.0); + }); + + test('treats a sample at the surface threshold as surfaced', () { + final points = [ + point(0, 10.0, {0: 200.0}), + point(300, kSurfaceThresholdMeters, {0: 150.0}), + point(600, 0.0, {0: 100.0}), + ]; + + final readings = surfacingTankReadings(points); + + expect(readings[0]!.atSurfacing, 200.0); + expect(readings[0]!.lastAfterSurfacing, 100.0); + }); + + test('omits a cylinder first seen after surfacing', () { + final points = [ + point(0, 10.0, {0: 200.0}), + point(600, 0.0, {0: 100.0, 1: 50.0}), + ]; + + expect(surfacingTankReadings(points), isNot(contains(1))); + }); + + test('returns nothing when the whole profile stays at the surface', () { + final points = [ + point(0, 0.0, {0: 200.0}), + point(300, 0.5, {0: 150.0}), + ]; + + expect(surfacingTankReadings(points), isEmpty); + }); + + test('returns nothing when no sample carries a pressure', () { + final points = [point(0, 10.0), point(300, 20.0), point(600, 0.0)]; + + expect(surfacingTankReadings(points), isEmpty); + }); + + test('returns nothing for an empty profile', () { + expect(surfacingTankReadings(const []), isEmpty); + }); + + test('orders by sample time rather than list position', () { + final points = [ + point(4090, 0.0, {1: 4.0}), + point(0, 0.0, {1: 200.0}), + point(3970, 1.2, {1: 41.0}), + point(600, 40.0, {1: 120.0}), + ]; + + final readings = surfacingTankReadings(points); + + expect(readings[1]!.atSurfacing, 41.0); + expect(readings[1]!.lastAfterSurfacing, 4.0); + }); + }); + + group('trimEndPressureBar', () { + const bleedingTail = SurfacingTankReading( + atSurfacing: 41.0, + lastAfterSurfacing: 4.0, + ); + + test('raises a reported pressure that came from the surface tail', () { + expect(trimEndPressureBar(reportedBar: 4.0, reading: bleedingTail), 41.0); + }); + + test('tolerates unit-conversion rounding when matching the tail', () { + expect( + trimEndPressureBar(reportedBar: 4.138, reading: bleedingTail), + 41.0, + ); + }); + + test('keeps a reported pressure that did not come from the tail', () { + // The source read its end pressure from somewhere other than the last + // sample -- a log header, or a transmitter that dropped out -- so there + // is no post-surfacing artifact to undo. + expect( + trimEndPressureBar(reportedBar: 86.6, reading: bleedingTail), + 86.6, + ); + }); + + test('keeps the reported pressure when the recording has no tail', () { + const noTail = SurfacingTankReading( + atSurfacing: 60.0, + lastAfterSurfacing: null, + ); + + expect(trimEndPressureBar(reportedBar: 60.0, reading: noTail), 60.0); + }); + + test('never lowers a reported pressure', () { + // A tail that somehow rose above the surfacing reading cannot drag the + // logged end pressure down. + const risingTail = SurfacingTankReading( + atSurfacing: 41.0, + lastAfterSurfacing: 60.0, + ); + + expect(trimEndPressureBar(reportedBar: 60.0, reading: risingTail), 60.0); + }); + + test('keeps the reported pressure when the profile has no reading', () { + expect(trimEndPressureBar(reportedBar: 4.0, reading: null), 4.0); + }); + + test('invents no pressure when none was reported', () { + expect( + trimEndPressureBar(reportedBar: null, reading: bleedingTail), + isNull, + ); + }); + }); +} diff --git a/test/features/dive_computer/data/services/parsed_dive_mapper_test.dart b/test/features/dive_computer/data/services/parsed_dive_mapper_test.dart index 7c47da61e2..7dfde6de96 100644 --- a/test/features/dive_computer/data/services/parsed_dive_mapper_test.dart +++ b/test/features/dive_computer/data/services/parsed_dive_mapper_test.dart @@ -63,6 +63,49 @@ void main() { ); } + // --- Tank pressure at surfacing (issue #1092) --- + + /// A CCR oxygen cylinder still bleeding through the constant mass flow + /// orifice while the computer records on the surface. + pigeon.ParsedDive bleedingOxygenDive() => makeParsedDive( + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0)], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 4.0, + ), + ], + samples: [ + pigeon.ProfileSample( + timeSeconds: 3970, + depthMeters: 1.2, + pressureBar: 41.0, + tankIndex: 0, + ), + pigeon.ProfileSample( + timeSeconds: 4140, + depthMeters: 0.0, + pressureBar: 4.0, + tankIndex: 0, + ), + ], + ); + + test('trims tank end pressure to the surfacing reading by default', () { + final downloaded = parsedDiveToDownloaded(bleedingOxygenDive()); + expect(downloaded.tanks.single.endPressure, 41.0); + }); + + test('keeps the computer end pressure when trimming is off', () { + final downloaded = parsedDiveToDownloaded( + bleedingOxygenDive(), + trimAtSurfacing: false, + ); + expect(downloaded.tanks.single.endPressure, 4.0); + }); + // --- Dive mode --- test('maps gauge mode and imports no tanks', () { diff --git a/test/features/dive_computer/data/services/parsed_tank_resolver_surfacing_test.dart b/test/features/dive_computer/data/services/parsed_tank_resolver_surfacing_test.dart new file mode 100644 index 0000000000..43c71a023b --- /dev/null +++ b/test/features/dive_computer/data/services/parsed_tank_resolver_surfacing_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/features/dive_computer/data/services/parsed_tank_resolver.dart'; + +/// Issue #1092: a rebreather's oxygen cylinder keeps bleeding down through the +/// constant mass flow orifice after the valve is closed on the surface, and the +/// computer is still recording. libdivecomputer's reported end pressure is +/// simply the last sample it saw, so it lands deep in that tail. +void main() { + group('resolveParsedTanks tank pressure at surfacing', () { + pigeon.ParsedDive makeParsedDive({ + List samples = const [], + List tanks = const [], + List gasMixes = const [], + }) { + return pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 6, + dateTimeDay: 20, + dateTimeHour: 9, + dateTimeMinute: 43, + dateTimeSecond: 2, + maxDepthMeters: 51.0, + avgDepthMeters: 30.0, + durationSeconds: 4140, + samples: samples, + tanks: tanks, + gasMixes: gasMixes, + events: const [], + ); + } + + pigeon.ProfileSample sample( + int t, + double depth, { + double? pressure, + int tankIndex = 0, + }) => pigeon.ProfileSample( + timeSeconds: t, + depthMeters: depth, + pressureBar: pressure, + tankIndex: pressure == null ? null : tankIndex, + gasMixIndex: 0, + ); + + /// The dive from the issue report: 41 bar of oxygen left at 1.2 m, 4 bar by + /// the time the recording stops two minutes later on the surface. + pigeon.ParsedDive bleedingOxygenDive() => makeParsedDive( + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0)], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 4.0, + ), + ], + samples: [ + sample(0, 0.0, pressure: 200.0), + sample(600, 51.0, pressure: 120.0), + sample(3970, 1.2, pressure: 41.0), + sample(4000, 0.0, pressure: 30.0), + sample(4140, 0.0, pressure: 4.0), + ], + ); + + test('reads end pressure at surfacing rather than at the end of the ' + 'recording', () { + final tanks = resolveParsedTanks( + bleedingOxygenDive(), + trimAtSurfacing: true, + ); + + expect(tanks.single.endPressure, 41.0); + }); + + test('keeps the computer end pressure when trimming is off', () { + final tanks = resolveParsedTanks( + bleedingOxygenDive(), + trimAtSurfacing: false, + ); + + expect(tanks.single.endPressure, 4.0); + }); + + test('leaves start pressure alone', () { + final tanks = resolveParsedTanks( + bleedingOxygenDive(), + trimAtSurfacing: true, + ); + + expect(tanks.single.startPressure, 200.0); + }); + + test('leaves a dive without a surface tail untouched', () { + final parsed = makeParsedDive( + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 21.0, hePercent: 0.0)], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 60.0, + ), + ], + samples: [ + sample(0, 5.0, pressure: 200.0), + sample(600, 20.0, pressure: 120.0), + sample(1800, 5.0, pressure: 60.0), + ], + ); + + final tanks = resolveParsedTanks(parsed, trimAtSurfacing: true); + + expect(tanks.single.endPressure, 60.0); + }); + + test('leaves a cylinder with no pressure samples untouched', () { + final parsed = makeParsedDive( + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 21.0, hePercent: 0.0)], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 60.0, + ), + ], + samples: [sample(0, 5.0), sample(600, 20.0), sample(1800, 0.0)], + ); + + final tanks = resolveParsedTanks(parsed, trimAtSurfacing: true); + + expect(tanks.single.endPressure, 60.0); + }); + + test('trims each transmitter against its own readings', () { + final parsed = makeParsedDive( + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 21.0, hePercent: 0.0), + pigeon.GasMix(index: 1, o2Percent: 100.0, hePercent: 0.0), + ], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 136.0, + ), + pigeon.TankInfo( + index: 1, + gasMixIndex: 1, + startPressureBar: 200.0, + endPressureBar: 4.0, + ), + ], + samples: [ + sample(600, 51.0, pressure: 160.0), + sample(600, 51.0, pressure: 120.0, tankIndex: 1), + sample(3970, 1.2, pressure: 136.0), + sample(3970, 1.2, pressure: 41.0, tankIndex: 1), + sample(4140, 0.0, pressure: 136.0), + sample(4140, 0.0, pressure: 4.0, tankIndex: 1), + ], + ); + + final tanks = resolveParsedTanks(parsed, trimAtSurfacing: true); + + // The diluent held steady, the oxygen bled away through the orifice. + expect(tanks.firstWhere((t) => t.index == 0).endPressure, 136.0); + expect(tanks.firstWhere((t) => t.index == 1).endPressure, 41.0); + }); + + test('trims by default so a caller cannot forget', () { + expect(resolveParsedTanks(bleedingOxygenDive()).single.endPressure, 41.0); + }); + }); +} diff --git a/test/features/dive_computer/data/services/reparse_service_surfacing_test.dart b/test/features/dive_computer/data/services/reparse_service_surfacing_test.dart new file mode 100644 index 0000000000..9f798ad786 --- /dev/null +++ b/test/features/dive_computer/data/services/reparse_service_surfacing_test.dart @@ -0,0 +1,111 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_computer/data/services/reparse_service.dart'; + +/// Issue #1092: reparsing stored raw bytes is how an already-imported dive +/// picks up the surfacing-pressure rule, so it has to honor the same setting +/// the live download does. +void main() { + late AppDatabase db; + + final nowMs = DateTime.utc(2026, 1, 15, 10, 0).millisecondsSinceEpoch; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + await db + .into(db.dives) + .insert( + DivesCompanion( + id: const Value('dive-1'), + diveDateTime: Value(nowMs), + notes: const Value(''), + createdAt: Value(nowMs), + updatedAt: Value(nowMs), + ), + ); + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion( + id: const Value('source-1'), + diveId: const Value('dive-1'), + isPrimary: const Value(true), + sourceFormat: const Value('dive_computer'), + importedAt: Value(DateTime.fromMillisecondsSinceEpoch(nowMs)), + createdAt: Value(DateTime.fromMillisecondsSinceEpoch(nowMs)), + ), + ); + }); + + tearDown(() => db.close()); + + /// The dive from the issue report: an oxygen cylinder reading 41 bar at + /// 1.2 m, bled to 4 bar by the time the recording stops on the surface. + pigeon.ParsedDive bleedingOxygenDive() => pigeon.ParsedDive( + fingerprint: 'test-fp', + dateTimeYear: 2026, + dateTimeMonth: 1, + dateTimeDay: 15, + dateTimeHour: 10, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 51.0, + avgDepthMeters: 30.0, + durationSeconds: 4140, + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0)], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 4.0, + ), + ], + samples: [ + pigeon.ProfileSample( + timeSeconds: 3970, + depthMeters: 1.2, + pressureBar: 41.0, + tankIndex: 0, + ), + pigeon.ProfileSample( + timeSeconds: 4140, + depthMeters: 0.0, + pressureBar: 4.0, + tankIndex: 0, + ), + ], + events: const [], + ); + + Future reparseAndReadEndPressure({required bool trim}) async { + final service = ReparseService(db: db, trimTankPressureAtSurfacing: trim); + await service.applyParsedUpdate( + diveId: 'dive-1', + sourceRowId: 'source-1', + parsed: bleedingOxygenDive(), + descriptorVendor: 'Shearwater', + descriptorProduct: 'Petrel 3', + descriptorModel: 42, + libdivecomputerVersion: '0.9.0', + ); + final tank = await (db.select( + db.diveTanks, + )..where((t) => t.diveId.equals('dive-1'))).getSingle(); + return tank.endPressure; + } + + test('reparse records the end pressure at surfacing', () async { + expect(await reparseAndReadEndPressure(trim: true), 41.0); + }); + + test( + 'reparse keeps the computer end pressure when trimming is off', + () async { + expect(await reparseAndReadEndPressure(trim: false), 4.0); + }, + ); +} diff --git a/test/features/dive_computer/presentation/providers/download_notifier_surfacing_test.dart b/test/features/dive_computer/presentation/providers/download_notifier_surfacing_test.dart new file mode 100644 index 0000000000..3719f65672 --- /dev/null +++ b/test/features/dive_computer/presentation/providers/download_notifier_surfacing_test.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:mockito/mockito.dart'; +import 'package:submersion/features/dive_computer/domain/entities/device_model.dart'; +import 'package:submersion/features/dive_computer/presentation/providers/download_providers.dart'; + +import 'download_notifier_fingerprint_test.mocks.dart'; + +/// Issue #1092: a live download has to read the diver's surfacing-pressure +/// preference at the moment each dive arrives, so the reading it stores is not +/// the one the computer logged minutes after the diver was back on the boat. +void main() { + late MockDiveComputerRepository mockRepository; + late MockDiveComputerService mockService; + late StreamController events; + + setUp(() { + mockRepository = MockDiveComputerRepository(); + mockService = MockDiveComputerService(); + events = StreamController.broadcast(); + when(mockService.downloadEvents).thenAnswer((_) => events.stream); + when( + mockService.startDownload(any, fingerprint: anyNamed('fingerprint')), + ).thenAnswer((_) async {}); + }); + + tearDown(() => events.close()); + + final device = DiscoveredDevice( + id: 'shearwater-1', + name: 'Petrel 3', + connectionType: DeviceConnectionType.ble, + address: '00:11:22:33:44:55', + discoveredAt: DateTime(2026, 1, 1), + ); + + /// The dive from the issue report: 41 bar of oxygen left at 1.2 m, bled to + /// 4 bar by the time the recording stops on the surface. + pigeon.ParsedDive bleedingOxygenDive() => pigeon.ParsedDive( + fingerprint: 'fp-1', + dateTimeYear: 2026, + dateTimeMonth: 1, + dateTimeDay: 15, + dateTimeHour: 10, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 51.0, + avgDepthMeters: 30.0, + durationSeconds: 4140, + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0)], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 4.0, + ), + ], + samples: [ + pigeon.ProfileSample( + timeSeconds: 3970, + depthMeters: 1.2, + pressureBar: 41.0, + tankIndex: 0, + ), + pigeon.ProfileSample( + timeSeconds: 4140, + depthMeters: 0.0, + pressureBar: 4.0, + tankIndex: 0, + ), + ], + events: const [], + ); + + Future downloadedEndPressure({required bool trim}) async { + final notifier = DownloadNotifier( + service: mockService, + repository: mockRepository, + trimTankPressureAtSurfacing: () => trim, + ); + addTearDown(notifier.dispose); + + await notifier.startDownload(device); + events.add(pigeon.DiveDownloadedEvent(bleedingOxygenDive())); + await Future.delayed(Duration.zero); + + return notifier.state.downloadedDives.single.tanks.single.endPressure; + } + + test( + 'stores the end pressure at surfacing when the diver opted in', + () async { + expect(await downloadedEndPressure(trim: true), 41.0); + }, + ); + + test('stores the computer end pressure when the diver opted out', () async { + expect(await downloadedEndPressure(trim: false), 4.0); + }); +} diff --git a/test/features/settings/data/repositories/diver_settings_repository_surfacing_pressure_test.dart b/test/features/settings/data/repositories/diver_settings_repository_surfacing_pressure_test.dart new file mode 100644 index 0000000000..834d3a9d6e --- /dev/null +++ b/test/features/settings/data/repositories/diver_settings_repository_surfacing_pressure_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/settings/data/repositories/diver_settings_repository.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + group('DiverSettingsRepository trimTankPressureAtSurfacing persistence', () { + late AppDatabase db; + late DiverSettingsRepository repository; + + setUp(() async { + db = await setUpTestDatabase(); + repository = DiverSettingsRepository(); + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.divers) + .insert( + DiversCompanion.insert( + id: 'd1', + name: 'Test Diver', + createdAt: now, + updatedAt: now, + ), + ); + }); + + tearDown(() { + DatabaseService.instance.resetForTesting(); + }); + + test('new settings default trimTankPressureAtSurfacing to true', () async { + await repository.createSettingsForDiver('d1'); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded, isNotNull); + expect(loaded!.trimTankPressureAtSurfacing, isTrue); + }); + + test( + 'round-trips trimTankPressureAtSurfacing = false through update', + () async { + await repository.createSettingsForDiver('d1'); + await repository.updateSettingsForDiver( + 'd1', + const AppSettings(trimTankPressureAtSurfacing: false), + ); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded, isNotNull); + expect(loaded!.trimTankPressureAtSurfacing, isFalse); + }, + ); + }); +} diff --git a/test/features/settings/presentation/pages/settings_page_shared_data_test.dart b/test/features/settings/presentation/pages/settings_page_shared_data_test.dart index 154aca94f1..d91ba3bdc8 100644 --- a/test/features/settings/presentation/pages/settings_page_shared_data_test.dart +++ b/test/features/settings/presentation/pages/settings_page_shared_data_test.dart @@ -403,6 +403,9 @@ class _MockSettingsNotifier extends StateNotifier Future setSiteMatchSensitivity(SiteMatchSensitivity value) async => state = state.copyWith(siteMatchSensitivity: value); @override + Future setTrimTankPressureAtSurfacing(bool value) async => + state = state.copyWith(trimTankPressureAtSurfacing: value); + @override Future setCardColorGradientPreset(String preset) async => state = state.copyWith(cardColorGradientPreset: preset); @override diff --git a/test/features/settings/presentation/pages/settings_page_surfacing_pressure_test.dart b/test/features/settings/presentation/pages/settings_page_surfacing_pressure_test.dart new file mode 100644 index 0000000000..783d5fa8d3 --- /dev/null +++ b/test/features/settings/presentation/pages/settings_page_surfacing_pressure_test.dart @@ -0,0 +1,71 @@ +// Issue #1092: the diver-facing control for reading cylinder end pressure at +// the moment of surfacing. It lives in Settings > Data, next to the other +// import-interpretation preference. + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/features/settings/presentation/pages/settings_page.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +void main() { + Widget buildDataSection(List overrides) { + final router = GoRouter( + initialLocation: '/settings?selected=data', + routes: [ + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + ], + ); + + return ProviderScope( + overrides: overrides, + child: MaterialApp.router( + locale: const Locale('en'), + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ); + } + + testWidgets('the tank pressure at surfacing toggle is on by default', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(buildDataSection(await getBaseOverrides())); + await tester.pumpAndSettle(); + + expect(find.text('Tank pressure at surfacing'), findsOneWidget); + final toggle = tester.widget( + find.ancestor( + of: find.text('Tank pressure at surfacing'), + matching: find.byType(SwitchListTile), + ), + ); + expect(toggle.value, isTrue); + }); + + testWidgets('turning it off records the diver preference', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final settings = MockSettingsNotifier(); + await tester.pumpWidget( + buildDataSection(await getBaseOverrides(settingsNotifier: settings)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Tank pressure at surfacing')); + await tester.pumpAndSettle(); + + expect(settings.state.trimTankPressureAtSurfacing, isFalse); + }); +} diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index 879c192902..1d465b740f 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -323,6 +323,9 @@ class _MockSettingsNotifier extends StateNotifier Future setSiteMatchSensitivity(SiteMatchSensitivity value) async => state = state.copyWith(siteMatchSensitivity: value); @override + Future setTrimTankPressureAtSurfacing(bool value) async => + state = state.copyWith(trimTankPressureAtSurfacing: value); + @override Future setCardColorGradientPreset(String preset) async => state = state.copyWith(cardColorGradientPreset: preset); @override diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index 74e46ee91e..9461f8c4d7 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -305,6 +305,9 @@ class _MockSettingsNotifier extends StateNotifier Future setSiteMatchSensitivity(SiteMatchSensitivity value) async => state = state.copyWith(siteMatchSensitivity: value); @override + Future setTrimTankPressureAtSurfacing(bool value) async => + state = state.copyWith(trimTankPressureAtSurfacing: value); + @override Future setCardColorGradientPreset(String preset) async => state = state.copyWith(cardColorGradientPreset: preset); @override diff --git a/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart b/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart new file mode 100644 index 0000000000..25546a5322 --- /dev/null +++ b/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.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/services/surfacing_pressure_normalizer.dart'; + +/// Issue #1092: an exporting app that took its end pressure from the last +/// sample it saw inherits the post-surfacing bleed-down, exactly as a dive +/// computer does. Every file format converges on this payload shape, so one +/// pass covers FIT, UDDF, DL7, Subsurface, Ratio and the rest. +void main() { + ImportPayload payloadWith(Map dive) => ImportPayload( + entities: { + ImportEntityType.dives: [dive], + }, + ); + + Map firstTank(ImportPayload payload) => + (payload.entitiesOf(ImportEntityType.dives).single['tanks'] + as List>) + .first; + + /// A rebreather oxygen cylinder still bleeding through its constant mass + /// flow orifice while the recording runs on at the surface. + Map bleedingOxygenDive() => { + 'tanks': >[ + {'order': 0, 'startPressure': 200.0, 'endPressure': 4.0}, + ], + 'profile': >[ + { + 'timestamp': 600, + 'depth': 51.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 120.0}, + ], + }, + { + 'timestamp': 3970, + 'depth': 1.2, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 41.0}, + ], + }, + { + 'timestamp': 4140, + 'depth': 0.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 4.0}, + ], + }, + ], + }; + + test('rewrites an end pressure that came from the surface tail', () { + final result = trimTankPressuresAtSurfacing( + payloadWith(bleedingOxygenDive()), + ); + + expect(firstTank(result)['endPressure'], 41.0); + }); + + test('leaves start pressure alone', () { + final result = trimTankPressuresAtSurfacing( + payloadWith(bleedingOxygenDive()), + ); + + expect(firstTank(result)['startPressure'], 200.0); + }); + + test('leaves the source payload unmutated', () { + final original = payloadWith(bleedingOxygenDive()); + + trimTankPressuresAtSurfacing(original); + + expect(firstTank(original)['endPressure'], 4.0); + }); + + test('keeps an end pressure the source did not take from the tail', () { + // The exporting app wrote its own value. Overriding it would replace a + // number with a provenance we know nothing about. + final dive = bleedingOxygenDive(); + (dive['tanks'] as List>).first['endPressure'] = 86.6; + + final result = trimTankPressuresAtSurfacing(payloadWith(dive)); + + expect(firstTank(result)['endPressure'], 86.6); + }); + + test('leaves a dive whose recording ends at the surface untouched', () { + final dive = bleedingOxygenDive(); + (dive['profile'] as List>).removeLast(); + (dive['tanks'] as List>).first['endPressure'] = 41.0; + + final result = trimTankPressuresAtSurfacing(payloadWith(dive)); + + expect(firstTank(result)['endPressure'], 41.0); + }); + + test('matches a cylinder by its position in the tanks list', () { + // allTankPressures.tankIndex indexes the tanks list, which is how the + // entity importer resolves it. + final dive = { + 'tanks': >[ + {'order': 3, 'endPressure': 136.0}, + {'order': 7, 'endPressure': 4.0}, + ], + 'profile': >[ + { + 'timestamp': 3970, + 'depth': 1.2, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 136.0}, + {'tankIndex': 1, 'pressure': 41.0}, + ], + }, + { + 'timestamp': 4140, + 'depth': 0.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 136.0}, + {'tankIndex': 1, 'pressure': 4.0}, + ], + }, + ], + }; + + final result = trimTankPressuresAtSurfacing(payloadWith(dive)); + final tanks = + result.entitiesOf(ImportEntityType.dives).single['tanks'] + as List>; + + expect(tanks[0]['endPressure'], 136.0); + expect(tanks[1]['endPressure'], 41.0); + }); + + test('leaves a dive with no profile untouched', () { + final dive = { + 'tanks': >[ + {'order': 0, 'endPressure': 4.0}, + ], + }; + + final result = trimTankPressuresAtSurfacing(payloadWith(dive)); + + expect(firstTank(result)['endPressure'], 4.0); + }); + + test('leaves a dive with no tanks untouched', () { + final dive = { + 'profile': >[ + {'timestamp': 0, 'depth': 10.0}, + ], + }; + + final result = trimTankPressuresAtSurfacing(payloadWith(dive)); + + expect( + result.entitiesOf(ImportEntityType.dives).single, + isNot(contains('tanks')), + ); + }); + + test('carries warnings, metadata and other entity types through', () { + final payload = ImportPayload( + entities: { + ImportEntityType.dives: [bleedingOxygenDive()], + ImportEntityType.sites: [ + {'name': 'Blue Hole'}, + ], + }, + warnings: const [ + ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'something to keep', + entityType: ImportEntityType.dives, + ), + ], + metadata: const {'sourceApp': 'Garmin'}, + ); + + final result = trimTankPressuresAtSurfacing(payload); + + expect(result.warnings, payload.warnings); + expect(result.metadata, payload.metadata); + expect( + result.entitiesOf(ImportEntityType.sites), + payload.entitiesOf(ImportEntityType.sites), + ); + }); +} diff --git a/test/features/universal_import/presentation/providers/universal_import_surfacing_pressure_test.dart b/test/features/universal_import/presentation/providers/universal_import_surfacing_pressure_test.dart new file mode 100644 index 0000000000..80c76d5b59 --- /dev/null +++ b/test/features/universal_import/presentation/providers/universal_import_surfacing_pressure_test.dart @@ -0,0 +1,156 @@ +// Issue #1092: proves the surfacing-pressure rule is actually reached on a +// real file import, and that the diver's setting governs it. The rule itself +// is unit-tested in test/core/profile/surfacing_pressure_test.dart. + +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.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 '../../../../helpers/mock_file_picker_platform.dart'; +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +/// A UDDF export whose end pressure was taken from the last sample: the diver +/// surfaced at 1.2 m with 41 bar of oxygen, and the recording ran on for two +/// more minutes while the cylinder bled to 4 bar through the constant mass +/// flow orifice. UDDF pressures are Pascal. +const _bleedingOxygenUddf = ''' + + + + 2026-01-15T10:00:00 + 51.04140.0 + + 20000000.0 + 400000.0 + + + 600.051.012000000.0 + 3970.01.24100000.0 + 4140.00.0400000.0 + + + + +'''; + +class _FakeFilePicker extends FilePickerPlatform + with MockPlatformInterfaceMixin { + List? nextPickPaths; + + @override + Future pickFile({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus)? onFileLoading, + int compressionQuality = 0, + AndroidOptions androidOptions = const AndroidOptions(), + WindowsOptions windowsOptions = const WindowsOptions(), + LinuxOptions linuxOptions = const LinuxOptions(), + WebOptions webOptions = const WebOptions(), + }) async { + final paths = nextPickPaths; + if (paths == null || paths.isEmpty) return null; + return FakePlatformFile(paths.first, name: p.basename(paths.first)); + } + + @override + Future> pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus)? onFileLoading, + int compressionQuality = 0, + AndroidOptions androidOptions = const AndroidOptions(), + WindowsOptions windowsOptions = const WindowsOptions(), + LinuxOptions linuxOptions = const LinuxOptions(), + WebOptions webOptions = const WebOptions(), + }) async { + final paths = nextPickPaths; + if (paths == null) return const []; + return [ + for (final path in paths) FakePlatformFile(path, name: p.basename(path)), + ]; + } +} + +Future _waitForAsyncWork(UniversalImportNotifier notifier) async { + for (var i = 0; i < 200; i++) { + await Future.delayed(Duration.zero); + if (!notifier.state.isLoading) break; + } +} + +void main() { + late ProviderContainer container; + late UniversalImportNotifier notifier; + late _FakeFilePicker picker; + late FilePickerPlatform originalPicker; + late Directory tmp; + + Future start({required bool trimAtSurfacing}) async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + settingsProvider.overrideWith( + (ref) => MockSettingsNotifier( + AppSettings(trimTankPressureAtSurfacing: trimAtSurfacing), + ), + ), + ], + ); + notifier = container.read(universalImportNotifierProvider.notifier); + originalPicker = FilePickerPlatform.instance; + picker = _FakeFilePicker(); + FilePickerPlatform.instance = picker; + tmp = await Directory.systemTemp.createTemp('surfacing_import_test'); + } + + tearDown(() async { + FilePickerPlatform.instance = originalPicker; + container.dispose(); + await tearDownTestDatabase(); + await tmp.delete(recursive: true); + }); + + Future importedEndPressure({required bool trimAtSurfacing}) async { + await start(trimAtSurfacing: trimAtSurfacing); + final file = File(p.join(tmp.path, 'dive.uddf')); + await file.writeAsString(_bleedingOxygenUddf); + picker.nextPickPaths = [file.path]; + + await notifier.pickFiles(); + await notifier.confirmSource(); + await _waitForAsyncWork(notifier); + + final dive = notifier.state.payload! + .entitiesOf(ImportEntityType.dives) + .single; + final tanks = dive['tanks'] as List>; + return (tanks.single['endPressure'] as num?)?.toDouble(); + } + + test('a file import stores the end pressure at surfacing', () async { + expect(await importedEndPressure(trimAtSurfacing: true), 41.0); + }); + + test( + 'a file import keeps the source end pressure when the diver opted out', + () async { + expect(await importedEndPressure(trimAtSurfacing: false), 4.0); + }, + ); +} diff --git a/test/helpers/mock_providers.dart b/test/helpers/mock_providers.dart index 269b46ce5c..7f9ac33faa 100644 --- a/test/helpers/mock_providers.dart +++ b/test/helpers/mock_providers.dart @@ -296,6 +296,9 @@ class MockSettingsNotifier extends StateNotifier Future setSiteMatchSensitivity(SiteMatchSensitivity value) async => state = state.copyWith(siteMatchSensitivity: value); @override + Future setTrimTankPressureAtSurfacing(bool value) async => + state = state.copyWith(trimTankPressureAtSurfacing: value); + @override Future setCardColorGradientPreset(String preset) async => state = state.copyWith(cardColorGradientPreset: preset); @override From 3eb050ebba40afde7c3c5ed991154b10471f8514 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 27 Aug 2026 01:11:35 -0400 Subject: [PATCH 2/2] fix(import): skip profile samples that carry no timestamp The payload adapter read a missing `timestamp` as second zero. The rule takes surfacing to be the latest sample deeper than the threshold, so a fabricated zero ranks an unstamped sample ahead of the whole dive: a profile that stamped only its tail placed surfacing at the start and promoted a mid-dive pressure into the end pressure. No parser emits a depth without a timestamp today (UDDF filters its waypoints on both keys, and the rest always write both), so this is a latent case rather than a live one. Skipping the unstamped samples, the way the depthless ones are already skipped, leaves such a dive uncorrected, which is what the rule does whenever it cannot tell where the dive ended. --- .../surfacing_pressure_normalizer.dart | 15 ++++- .../surfacing_pressure_normalizer_test.dart | 61 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart b/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart index cba9d4fc78..da3875a229 100644 --- a/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart +++ b/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart @@ -68,13 +68,24 @@ Map _trimDive(Map dive) { } /// Reduce payload profile points to what the surfacing rule reads. A point -/// without a depth cannot place the surfacing moment, so it is skipped. +/// missing either a depth or a timestamp cannot place the surfacing moment, so +/// it is skipped. +/// +/// Skipping the untimed ones matters as much as skipping the depthless ones. +/// The rule takes surfacing to be the latest deep sample, so reading a missing +/// timestamp as zero would rank every untimed sample ahead of the whole dive: +/// a profile that stamped only its tail would place surfacing at the start and +/// promote a mid-dive pressure into the end pressure. Dropping them instead +/// leaves such a dive uncorrected, which is what this rule does whenever it +/// cannot tell where the dive ended. List _points(List profile) { final points = []; for (final raw in profile) { if (raw is! Map) continue; final depth = (raw['depth'] as num?)?.toDouble(); if (depth == null) continue; + final timeSeconds = (raw['timestamp'] as num?)?.toInt(); + if (timeSeconds == null) continue; final pressures = {}; final all = raw['allTankPressures']; @@ -91,7 +102,7 @@ List _points(List profile) { points.add( SurfacingProfilePoint( - timeSeconds: (raw['timestamp'] as num?)?.toInt() ?? 0, + timeSeconds: timeSeconds, depthMeters: depth, tankPressuresBar: pressures, ), diff --git a/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart b/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart index 25546a5322..b89ba990e0 100644 --- a/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart +++ b/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart @@ -187,4 +187,65 @@ void main() { payload.entitiesOf(ImportEntityType.sites), ); }); + + test('leaves a dive whose deep samples carry no timestamp untouched', () { + // A source that stamps only some of its samples cannot place surfacing. + // Reading the unstamped ones as time zero would rank them before every + // stamped sample, moving surfacing to the start of the dive and promoting + // a mid-dive pressure into the end pressure. + final dive = { + 'tanks': >[ + {'order': 0, 'startPressure': 200.0, 'endPressure': 4.0}, + ], + 'profile': >[ + { + 'depth': 51.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 120.0}, + ], + }, + { + 'depth': 30.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 100.0}, + ], + }, + { + 'timestamp': 3970, + 'depth': 0.5, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 41.0}, + ], + }, + { + 'timestamp': 4140, + 'depth': 0.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 4.0}, + ], + }, + ], + }; + + final result = trimTankPressuresAtSurfacing(payloadWith(dive)); + + expect(firstTank(result)['endPressure'], 4.0); + }); + + test('ignores an unstamped sample when reading the surfacing pressure', () { + // The unstamped sample sits in the middle of the descent. Dropping it + // leaves the stamped samples to place surfacing, so the correction still + // lands on the reading at 1.2 m rather than on the mid-dive value. + final dive = bleedingOxygenDive(); + (dive['profile'] as List>).insert(1, { + 'depth': 30.0, + 'allTankPressures': [ + {'tankIndex': 0, 'pressure': 100.0}, + ], + }); + + final result = trimTankPressuresAtSurfacing(payloadWith(dive)); + + expect(firstTank(result)['endPressure'], 41.0); + }); }