diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 54a66723e7..43edb9e160 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1779,6 +1779,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 (v165, issue #1092). + BoolColumn get trimTankPressureAtSurfacing => + boolean().withDefault(const Constant(true))(); // Dive profile chart defaults TextColumn get defaultRightAxisMetric => text().withDefault(const Constant('temperature'))(); @@ -3480,10 +3484,13 @@ class AppDatabase extends _$AppDatabase { // media item in the dive when its capture time is wrong (issue #1090). // Renumbered from 162, which #731 landed past while this branch was open. 164, - // v165 is deliberately absent, not missing: it is claimed by issue #1092 - // (PR #1290, diver_settings.trim_tank_pressure_at_surfacing) on a branch - // that is still open. This ladder is monotonic and unique, not - // contiguous, so do not "fix" the gap by renumbering downwards. + // v165: 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). + // Renumbered from 163, which #731 landed on main while this branch + // was open. Main reserved this number while the branch was open, so it + // lands here without renumbering. + 165, // v166: diver_settings.place_name_language, the synced language used for // reverse-geocoded country/region/town/body of water (issue #1187). // Renumbered from 162, which #731 landed past while this branch was open. @@ -5008,6 +5015,26 @@ class AppDatabase extends _$AppDatabase { } } + /// v165: 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))', + ); + } + } + /// v166: place_name_language on diver_settings (issue #1187). Defaults to /// 'en', the language every pre-v166 row was geocoded in (issue #214). Future _assertPlaceNameLanguageColumn() async { @@ -8665,6 +8692,11 @@ class AppDatabase extends _$AppDatabase { await _assertMediaManualElapsedColumn(); } if (from < 164) await reportProgress(); + // v165: trim_tank_pressure_at_surfacing on diver_settings (#1092). + if (from < 165) { + await _assertSurfacingPressureColumn(); + } + if (from < 165) await reportProgress(); // v166: place_name_language on diver_settings (issue #1187). if (from < 166) { await _assertPlaceNameLanguageColumn(); @@ -8880,6 +8912,9 @@ class AppDatabase extends _$AppDatabase { // #1090; same parallel-branch version-collision self-heal). The // media row mapper reads it on every hydration. await _assertMediaManualElapsedColumn(); + // v165 backstop: re-assert diver_settings.trim_tank_pressure_at_ + // surfacing (issue #1092; same parallel-branch collision self-heal). + await _assertSurfacingPressureColumn(); // v168 backstop: re-assert buddies.is_favorite (issue #638). A // database that arrives by restore or sync-adopt never runs 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 e48835ec9f..ad8044de0f 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 @@ -86,7 +93,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 7686334a26..1caf477910 100644 --- a/lib/features/dive_computer/data/services/reparse_service.dart +++ b/lib/features/dive_computer/data/services/reparse_service.dart @@ -18,7 +18,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. @@ -672,7 +678,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 5ed916c089..dba7110d6b 100644 --- a/lib/features/dive_computer/presentation/providers/download_providers.dart +++ b/lib/features/dive_computer/presentation/providers/download_providers.dart @@ -150,11 +150,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. @@ -245,7 +253,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], ); @@ -338,7 +349,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 a698dc6fea..1f90d2a5ee 100644 --- a/lib/features/settings/data/repositories/diver_settings_repository.dart +++ b/lib/features/settings/data/repositories/diver_settings_repository.dart @@ -145,6 +145,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), @@ -311,6 +312,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), @@ -525,6 +529,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 53e9f4659b..64d4a41e6b 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -2589,6 +2589,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 7c9c9afca1..5deb45d356 100644 --- a/lib/features/settings/presentation/providers/settings_providers.dart +++ b/lib/features/settings/presentation/providers/settings_providers.dart @@ -306,6 +306,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; @@ -541,6 +545,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, @@ -703,6 +708,7 @@ class AppSettings { ListViewMode? diveCenterListViewMode, MapStyle? mapStyle, SiteMatchSensitivity? siteMatchSensitivity, + bool? trimTankPressureAtSurfacing, String? cardColorGradientPreset, int? cardColorGradientStart, int? cardColorGradientEnd, @@ -842,6 +848,8 @@ class AppSettings { diveCenterListViewMode ?? this.diveCenterListViewMode, mapStyle: mapStyle ?? this.mapStyle, siteMatchSensitivity: siteMatchSensitivity ?? this.siteMatchSensitivity, + trimTankPressureAtSurfacing: + trimTankPressureAtSurfacing ?? this.trimTankPressureAtSurfacing, cardColorGradientPreset: cardColorGradientPreset ?? this.cardColorGradientPreset, cardColorGradientStart: clearCardColorGradientStart @@ -1669,6 +1677,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..da3875a229 --- /dev/null +++ b/lib/features/universal_import/data/services/surfacing_pressure_normalizer.dart @@ -0,0 +1,112 @@ +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 +/// 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']; + 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: timeSeconds, + 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 65ebdca289..1b213907c1 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -38,6 +38,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/domain/services/import_media_resolver.dart'; @@ -781,7 +782,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); @@ -809,17 +812,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 @@ -884,6 +888,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 3532bcdd9d..a9b26a3d55 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -6189,6 +6189,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 e69a087e22..b360d267b3 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -6189,6 +6189,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 79f6394496..4f716c2a9a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -14372,6 +14372,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 8728a16f98..7c72ce9a9c 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -6189,6 +6189,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 712c5f336e..39172fce73 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -6116,6 +6116,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 c20c349ca6..229092a91a 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -6189,6 +6189,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 380812d81e..92a3c6abff 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -6116,6 +6116,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 7e31877c1a..23c9cb3396 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -6112,6 +6112,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 5b7e7fd5b4..5ff193780a 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -37263,6 +37263,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 0648573605..b53e7b02cb 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -22138,6 +22138,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 643a1daec4..04cceccf85 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -22494,6 +22494,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 6f8b32ca5e..0eb6e4fa01 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -22155,6 +22155,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 e4a3b55375..d87228a8f1 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -22553,6 +22553,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 76bf1a2af2..f4a55ace28 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -22607,6 +22607,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 8add16d942..5b1bfaad30 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -21976,6 +21976,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 7c143e0898..be33e240bb 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -22455,6 +22455,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 f875054a43..c39cffe8cb 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -22531,6 +22531,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 30a4d0017f..160925ccef 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -22361,6 +22361,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 677941665b..2f16b30bac 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -22534,6 +22534,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 c2c85687c8..a860160395 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -21383,6 +21383,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 822b2619e0..28ab816ee6 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -6189,6 +6189,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 03eb964da9..e4af1357be 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -6189,6 +6189,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 3ffd2c74dc..82f0289e06 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -5127,6 +5127,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_v165_surfacing_pressure_test.dart b/test/core/database/migration_v165_surfacing_pressure_test.dart new file mode 100644 index 0000000000..d97ea07c48 --- /dev/null +++ b/test/core/database/migration_v165_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-v165 shape: a diver_settings table without the surfacing +/// pressure column, stamped at v161 so the upgrade to 165 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('v165 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('v165 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(165)); + expect(AppDatabase.migrationVersions, contains(165)); + }); +} 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 ba744c98a8..500d4b7b50 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 @@ -406,6 +406,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 7f3b30819e..766fb124f4 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -329,6 +329,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 9214087c42..8427f27f73 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -311,6 +311,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..b89ba990e0 --- /dev/null +++ b/test/features/universal_import/data/services/surfacing_pressure_normalizer_test.dart @@ -0,0 +1,251 @@ +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), + ); + }); + + 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); + }); +} 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 389cdac9af..863343fd96 100644 --- a/test/helpers/mock_providers.dart +++ b/test/helpers/mock_providers.dart @@ -299,6 +299,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