From 98ad74e8c4b3ebd8fe1b9c1eb7764e5978982bb9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:38:08 +0000 Subject: [PATCH 001/122] Add Deco: Yes/No filter to Dives / Advanced Search Adds a decompression status filter to the Advanced Search page, so dives can be narrowed to deco/no-deco without relying on the existing "Technical" dive-type filter. Deco status has no stored column; it's derived from the recorded profile signal (dive_profiles.deco_type / ceiling and dive_profile_events), mirroring the classification already used by the Decompression Obligation statistic, so no schema migration is needed. Resolves submersion-app/submersion#642. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- .../repositories/dive_repository_impl.dart | 5 + .../domain/models/dive_filter_state.dart | 33 +++++ .../presentation/pages/dive_search_page.dart | 42 ++++++- .../statistics/data/dive_filter_sql.dart | 47 +++++++ lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 1 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 3 + lib/l10n/arb/app_localizations_de.dart | 3 + lib/l10n/arb/app_localizations_en.dart | 3 + lib/l10n/arb/app_localizations_es.dart | 3 + lib/l10n/arb/app_localizations_fr.dart | 3 + lib/l10n/arb/app_localizations_he.dart | 3 + lib/l10n/arb/app_localizations_hu.dart | 3 + lib/l10n/arb/app_localizations_it.dart | 3 + lib/l10n/arb/app_localizations_nl.dart | 3 + lib/l10n/arb/app_localizations_pt.dart | 3 + lib/l10n/arb/app_localizations_zh.dart | 3 + lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + .../dive_repository_deco_filter_test.dart | 84 +++++++++++++ .../domain/models/dive_filter_state_test.dart | 117 +++++++++++++++++- .../statistics/data/dive_filter_sql_test.dart | 71 +++++++++++ 30 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart diff --git a/lib/features/dive_log/data/repositories/dive_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_repository_impl.dart index 7f31239cfb..5fb6581bde 100644 --- a/lib/features/dive_log/data/repositories/dive_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_repository_impl.dart @@ -2022,6 +2022,11 @@ class DiveRepository { if (filter.favoritesOnly == true) { clauses.add('d.is_favorite = 1'); } + if (filter.decoOnly != null) { + clauses.add( + decoSignalCondition(wantDeco: filter.decoOnly!, diveIdRef: 'd.id'), + ); + } if (filter.tagIds.isNotEmpty) { final placeholders = List.filled(filter.tagIds.length, '?').join(', '); clauses.add( diff --git a/lib/features/dive_log/domain/models/dive_filter_state.dart b/lib/features/dive_log/domain/models/dive_filter_state.dart index 453e4914c6..e19a079030 100644 --- a/lib/features/dive_log/domain/models/dive_filter_state.dart +++ b/lib/features/dive_log/domain/models/dive_filter_state.dart @@ -16,6 +16,13 @@ class DiveFilterState { final double? minDepth; final double? maxDepth; final bool? favoritesOnly; + /// Decompression status, from the recorded profile signal (deco stop type, + /// deco-stop events, or a positive ceiling with no deco-type data at all — + /// see `scanRecordedDecoSignals` in StatisticsRepository). Null means no + /// filter; true/false restrict to deco/no-deco dives. Dives whose status is + /// unrecorded (no profile, or a profile needing the computed fallback) + /// match neither. + final bool? decoOnly; final List tagIds; // v1.5: Additional filter criteria @@ -56,6 +63,7 @@ class DiveFilterState { this.minDepth, this.maxDepth, this.favoritesOnly, + this.decoOnly, this.tagIds = const [], this.equipmentIds = const [], this.buddyNameFilter, @@ -85,6 +93,7 @@ class DiveFilterState { minDepth != null || maxDepth != null || favoritesOnly == true || + decoOnly != null || tagIds.isNotEmpty || equipmentIds.isNotEmpty || (buddyNameFilter != null && buddyNameFilter!.isNotEmpty) || @@ -109,6 +118,7 @@ class DiveFilterState { double? minDepth, double? maxDepth, bool? favoritesOnly, + bool? decoOnly, List? tagIds, List? equipmentIds, String? buddyNameFilter, @@ -135,6 +145,7 @@ class DiveFilterState { bool clearMinDepth = false, bool clearMaxDepth = false, bool clearFavoritesOnly = false, + bool clearDecoOnly = false, bool clearTagIds = false, bool clearEquipmentIds = false, bool clearBuddyNameFilter = false, @@ -164,6 +175,7 @@ class DiveFilterState { favoritesOnly: clearFavoritesOnly ? null : (favoritesOnly ?? this.favoritesOnly), + decoOnly: clearDecoOnly ? null : (decoOnly ?? this.decoOnly), tagIds: clearTagIds ? const [] : (tagIds ?? this.tagIds), equipmentIds: clearEquipmentIds ? const [] @@ -254,6 +266,9 @@ class DiveFilterState { if (favoritesOnly == true && !dive.isFavorite) { return false; } + if (decoOnly != null && !_matchesDecoFilter(dive, decoOnly!)) { + return false; + } if (tagIds.isNotEmpty) { final diveTagIds = dive.tags.map((t) => t.id).toSet(); if (!tagIds.any((tagId) => diveTagIds.contains(tagId))) { @@ -357,3 +372,21 @@ class DiveFilterState { }).toList(); } } + +/// Recorded-signal deco classification, mirroring +/// `StatisticsRepository.scanRecordedDecoSignals` (SQL) using the profile +/// points already hydrated on [dive]. Deco-stop *events* are not loaded onto +/// the entity, so unlike the SQL path this only sees the deco-type/ceiling +/// signal; that gap only matters for dives whose computer logs a deco-stop +/// event without also writing profile deco_type/ceiling data, which the +/// paginated (SQL-backed) dive list still classifies correctly. +bool _matchesDecoFilter(Dive dive, bool wantDeco) { + final hasDecoType = dive.profile.any((p) => p.decoType != null); + final hasDecoStop = dive.profile.any((p) => p.decoType == 2); + final hasPositiveCeiling = dive.profile.any( + (p) => p.ceiling != null && p.ceiling! > 0, + ); + final isDeco = hasDecoStop || (!hasDecoType && hasPositiveCeiling); + final isNoDeco = hasDecoType && !hasDecoStop; + return wantDeco ? isDeco : isNoDeco; +} diff --git a/lib/features/dive_log/presentation/pages/dive_search_page.dart b/lib/features/dive_log/presentation/pages/dive_search_page.dart index 70ef075a60..96ad5f1481 100644 --- a/lib/features/dive_log/presentation/pages/dive_search_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_search_page.dart @@ -55,6 +55,7 @@ class _DiveSearchPageState extends ConsumerState { double? _maxDepth; int? _minDurationMinutes; int? _maxDurationMinutes; + bool? _decoOnly; // Gas & Equipment String? _diveTypeId; @@ -116,6 +117,7 @@ class _DiveSearchPageState extends ConsumerState { _maxDepth = filter.maxDepth; _minDurationMinutes = filter.minBottomTimeMinutes; _maxDurationMinutes = filter.maxBottomTimeMinutes; + _decoOnly = filter.decoOnly; _diveTypeId = filter.diveTypeId; _minO2Percent = filter.minO2Percent; _maxO2Percent = filter.maxO2Percent; @@ -143,7 +145,8 @@ class _DiveSearchPageState extends ConsumerState { if (_minDepth != null || _maxDepth != null || _minDurationMinutes != null || - _maxDurationMinutes != null) { + _maxDurationMinutes != null || + _decoOnly != null) { _expanded['conditions'] = true; } if (_diveTypeId != null || @@ -538,6 +541,41 @@ class _DiveSearchPageState extends ConsumerState { ), ], ), + const SizedBox(height: 24), + + // Decompression + Text( + context.l10n.diveLog_search_label_deco, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ChoiceChip( + label: Text(context.l10n.diveSites_filter_difficulty_any), + selected: _decoOnly == null, + onSelected: (selected) { + if (selected) setState(() => _decoOnly = null); + }, + ), + ChoiceChip( + label: Text(context.l10n.attr_flagYes), + selected: _decoOnly == true, + onSelected: (selected) { + if (selected) setState(() => _decoOnly = true); + }, + ), + ChoiceChip( + label: Text(context.l10n.attr_flagNo), + selected: _decoOnly == false, + onSelected: (selected) { + if (selected) setState(() => _decoOnly = false); + }, + ), + ], + ), ], ); } @@ -783,6 +821,7 @@ class _DiveSearchPageState extends ConsumerState { _maxDepth = null; _minDurationMinutes = null; _maxDurationMinutes = null; + _decoOnly = null; _diveTypeId = null; _minO2Percent = null; _maxO2Percent = null; @@ -815,6 +854,7 @@ class _DiveSearchPageState extends ConsumerState { maxDepth: _maxDepth, minBottomTimeMinutes: _minDurationMinutes, maxBottomTimeMinutes: _maxDurationMinutes, + decoOnly: _decoOnly, diveTypeId: _diveTypeId, minO2Percent: _minO2Percent, maxO2Percent: _maxO2Percent, diff --git a/lib/features/statistics/data/dive_filter_sql.dart b/lib/features/statistics/data/dive_filter_sql.dart index b52942c6d3..449f1b3e27 100644 --- a/lib/features/statistics/data/dive_filter_sql.dart +++ b/lib/features/statistics/data/dive_filter_sql.dart @@ -116,6 +116,12 @@ import 'package:submersion/features/equipment/domain/constants/equipment_attribu conditions.add('is_favorite = 1'); } + if (filter.decoOnly != null) { + conditions.add( + decoSignalCondition(wantDeco: filter.decoOnly!, diveIdRef: 'dives.id'), + ); + } + // Buddy free-text: case-insensitive substring against the legacy scalar // column OR any junction-linked buddy's name. The dive editor writes only // the dive_buddies junction; the scalar covers old data (#757). @@ -206,3 +212,44 @@ import 'package:submersion/features/equipment/domain/constants/equipment_attribu params: params, ); } + +/// Recorded deco-signal SQL condition (no bind params), shared by +/// [buildFilteredDiveIdSubquery] and +/// `DiveRepositoryImpl._buildFilterWhereClauses` so the two SQL paths +/// (Statistics vs. the paginated dive list) can't drift apart. Mirrors +/// `StatisticsRepository.scanRecordedDecoSignals`: +/// +/// - A deco-stop profile point (`deco_type = 2`) or a `decoStopStart` event +/// means deco. +/// - A profile that carries `deco_type` values, none of which is 2, means +/// no-deco: the computer recorded obligations and reported none. +/// - A positive `ceiling` on a profile with no `deco_type` at all also means +/// deco (some import sources only ever write a stop depth). +/// - A dive with no qualifying profile data matches neither branch; it is +/// only classifiable via the computed fallback, which this SQL-only axis +/// does not have access to. +/// +/// [diveIdRef] must be a reference to the enclosing query's `dives.id` +/// resolvable from inside these correlated subqueries (e.g. `d.id` when the +/// caller aliases `dives` as `d`, or `dives.id` when it does not). +String decoSignalCondition({ + required bool wantDeco, + required String diveIdRef, +}) { + final hasDecoStop = + 'EXISTS (SELECT 1 FROM dive_profiles p ' + 'WHERE p.dive_id = $diveIdRef AND p.deco_type = 2) ' + "OR EXISTS (SELECT 1 FROM dive_profile_events e " + "WHERE e.dive_id = $diveIdRef AND e.event_type = 'decoStopStart')"; + final hasDecoType = + 'EXISTS (SELECT 1 FROM dive_profiles p ' + 'WHERE p.dive_id = $diveIdRef AND p.deco_type IS NOT NULL)'; + final hasPositiveCeiling = + 'EXISTS (SELECT 1 FROM dive_profiles p ' + 'WHERE p.dive_id = $diveIdRef AND p.ceiling > 0)'; + + if (wantDeco) { + return '($hasDecoStop OR (NOT ($hasDecoType) AND $hasPositiveCeiling))'; + } + return '($hasDecoType AND NOT ($hasDecoStop))'; +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index a7d713bce5..044a4189c2 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2309,6 +2309,7 @@ "diveLog_search_errorLoadingDiveTypes": "خطأ في تحميل أنواع الغوص", "diveLog_search_errorLoadingTrips": "خطأ في تحميل الرحلات", "diveLog_search_gasTrimix": "ترايمكس (<21% O₂)", + "diveLog_search_label_deco": "تخفيف الضغط", "diveLog_search_label_depthRange": "نطاق العمق (m)", "diveLog_search_label_diveCenter": "مركز الغوص", "diveLog_search_label_diveSite": "موقع غوص", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index be19ff6cba..c197a27d9c 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -2309,6 +2309,7 @@ "diveLog_search_errorLoadingDiveTypes": "Fehler beim Laden der Tauchgangstypen", "diveLog_search_errorLoadingTrips": "Fehler beim Laden der Reisen", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Dekompression", "diveLog_search_label_depthRange": "Tiefenbereich (m)", "diveLog_search_label_diveCenter": "Tauchbasis", "diveLog_search_label_diveSite": "Tauchplatz", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 97349f2e8e..6e6de558b2 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3787,6 +3787,7 @@ "diveLog_search_errorLoadingDiveTypes": "Error loading dive types", "diveLog_search_errorLoadingTrips": "Error loading trips", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Decompression", "diveLog_search_label_depthRange": "Depth Range (m)", "diveLog_search_label_diveCenter": "Dive Center", "diveLog_search_label_diveSite": "Dive Site", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index a507065b23..98660f6edf 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2309,6 +2309,7 @@ "diveLog_search_errorLoadingDiveTypes": "Error al cargar tipos de inmersión", "diveLog_search_errorLoadingTrips": "Error al cargar los viajes", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Descompresión", "diveLog_search_label_depthRange": "Rango de profundidad (m)", "diveLog_search_label_diveCenter": "Centro de buceo", "diveLog_search_label_diveSite": "Punto de buceo", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 2474a861cc..98e77377f0 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -2236,6 +2236,7 @@ "diveLog_search_errorLoadingDiveTypes": "Erreur lors du chargement des types de plongée", "diveLog_search_errorLoadingTrips": "Erreur de chargement des voyages", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Décompression", "diveLog_search_label_depthRange": "Plage de profondeur (m)", "diveLog_search_label_diveCenter": "Centre de plongee", "diveLog_search_label_diveSite": "Site de plongee", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 717ba94225..c9313b9db7 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -2236,6 +2236,7 @@ "diveLog_search_errorLoadingDiveTypes": "שגיאה בטעינת סוגי צלילה", "diveLog_search_errorLoadingTrips": "שגיאה בטעינת טיולים", "diveLog_search_gasTrimix": "טריימיקס (<21% O₂)", + "diveLog_search_label_deco": "דקומפרסיה", "diveLog_search_label_depthRange": "טווח עומק (m)", "diveLog_search_label_diveCenter": "מרכז צלילה", "diveLog_search_label_diveSite": "אתר צלילה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index d845a36417..5dcf904ea2 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -2236,6 +2236,7 @@ "diveLog_search_errorLoadingDiveTypes": "Hiba a merülés típusok betöltésekor", "diveLog_search_errorLoadingTrips": "Hiba az utazasok betoltesekor", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Dekompresszio", "diveLog_search_label_depthRange": "Melyseg tartomany (m)", "diveLog_search_label_diveCenter": "Merulokozpont", "diveLog_search_label_diveSite": "Merulohely", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 8aa23eac32..8844aec3de 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -2236,6 +2236,7 @@ "diveLog_search_errorLoadingDiveTypes": "Errore durante il caricamento dei tipi di immersione", "diveLog_search_errorLoadingTrips": "Errore nel caricamento dei viaggi", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Decompressione", "diveLog_search_label_depthRange": "Intervallo profondita (m)", "diveLog_search_label_diveCenter": "Centro immersioni", "diveLog_search_label_diveSite": "Sito di immersione", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index d978ffd7e4..1a8a6a425a 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -11879,6 +11879,12 @@ abstract class AppLocalizations { /// **'Trimix (<21% O₂)'** String get diveLog_search_gasTrimix; + /// No description provided for @diveLog_search_label_deco. + /// + /// In en, this message translates to: + /// **'Decompression'** + String get diveLog_search_label_deco; + /// No description provided for @diveLog_search_label_depthRange. /// /// 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 b9622a69e4..857b2c7ea0 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -6927,6 +6927,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'ترايمكس (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'تخفيف الضغط'; + @override String get diveLog_search_label_depthRange => 'نطاق العمق (m)'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 459c25d2b4..6b3221a3ed 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -7069,6 +7069,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Dekompression'; + @override String get diveLog_search_label_depthRange => 'Tiefenbereich (m)'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index fbfe533f62..a091892c50 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -6940,6 +6940,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Decompression'; + @override String get diveLog_search_label_depthRange => 'Depth Range (m)'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 3ff1e0c4b4..3ee52e48d1 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -7075,6 +7075,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Descompresión'; + @override String get diveLog_search_label_depthRange => 'Rango de profundidad (m)'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 103381dcf8..8a321a4545 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -7101,6 +7101,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Décompression'; + @override String get diveLog_search_label_depthRange => 'Plage de profondeur (m)'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index d44a3c137d..8af8251ede 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -6891,6 +6891,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'טריימיקס (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'דקומפרסיה'; + @override String get diveLog_search_label_depthRange => 'טווח עומק (m)'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 3d65617fad..95bde9c456 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -7050,6 +7050,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Dekompresszio'; + @override String get diveLog_search_label_depthRange => 'Melyseg tartomany (m)'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 8853dee74c..d1c57c717e 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -7074,6 +7074,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Decompressione'; + @override String get diveLog_search_label_depthRange => 'Intervallo profondita (m)'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 18ba60db2f..35d93a62fa 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -7016,6 +7016,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Decompressie'; + @override String get diveLog_search_label_depthRange => 'Dieptebereik (m)'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 734a85fb95..c17e8defa0 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -7074,6 +7074,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; + @override + String get diveLog_search_label_deco => 'Descompressão'; + @override String get diveLog_search_label_depthRange => 'Faixa de Profundidade (m)'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index e933691787..a7ab85277e 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -6721,6 +6721,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveLog_search_gasTrimix => '三混气 (<21% O₂)'; + @override + String get diveLog_search_label_deco => '减压'; + @override String get diveLog_search_label_depthRange => '深度范围(米)'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index f7f91fca0f..60cd084651 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -2309,6 +2309,7 @@ "diveLog_search_errorLoadingDiveTypes": "Fout bij laden duiktypes", "diveLog_search_errorLoadingTrips": "Fout bij laden van reizen", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Decompressie", "diveLog_search_label_depthRange": "Dieptebereik (m)", "diveLog_search_label_diveCenter": "Duikcentrum", "diveLog_search_label_diveSite": "Duikstek", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 169150ad96..b3776c35fa 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2309,6 +2309,7 @@ "diveLog_search_errorLoadingDiveTypes": "Erro ao carregar tipos de mergulho", "diveLog_search_errorLoadingTrips": "Erro ao carregar viagens", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", + "diveLog_search_label_deco": "Descompressão", "diveLog_search_label_depthRange": "Faixa de Profundidade (m)", "diveLog_search_label_diveCenter": "Centro de Mergulho", "diveLog_search_label_diveSite": "Ponto de Mergulho", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 4794902d1d..e993e1188b 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2442,6 +2442,7 @@ "diveLog_search_errorLoadingDiveTypes": "加载潜水类型出错", "diveLog_search_errorLoadingTrips": "加载旅行出错", "diveLog_search_gasTrimix": "三混气 (<21% O₂)", + "diveLog_search_label_deco": "减压", "diveLog_search_label_depthRange": "深度范围(米)", "diveLog_search_label_diveCenter": "潜水中心", "diveLog_search_label_diveSite": "潜水点", diff --git a/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart b/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart new file mode 100644 index 0000000000..957d45d1d0 --- /dev/null +++ b/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as domain; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late DiveRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = DiveRepository(); + }); + tearDown(() async => tearDownTestDatabase()); + + test( + 'decoOnly: true matches a recorded deco stop, decoOnly: false matches ' + 'a recorded no-deco profile', + () async { + await repository.createDive( + domain.Dive( + id: 'deco', + dateTime: DateTime(2026, 1, 1), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 0), + domain.DiveProfilePoint(timestamp: 60, depth: 30, decoType: 2), + ], + ), + ); + await repository.createDive( + domain.Dive( + id: 'noDeco', + dateTime: DateTime(2026, 1, 2), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), + ], + ), + ); + await repository.createDive( + domain.Dive(id: 'unrecorded', dateTime: DateTime(2026, 1, 3)), + ); + + final decoResults = await repository.getDiveSummaries( + filter: const DiveFilterState(decoOnly: true), + ); + expect(decoResults.map((d) => d.id).toSet(), {'deco'}); + + final noDecoResults = await repository.getDiveSummaries( + filter: const DiveFilterState(decoOnly: false), + ); + expect(noDecoResults.map((d) => d.id).toSet(), {'noDeco'}); + }, + ); + + test('in-memory apply() agrees with the SQL path', () { + final dives = [ + domain.Dive( + id: 'deco', + dateTime: DateTime(2026, 1, 1), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), + ], + ), + domain.Dive( + id: 'noDeco', + dateTime: DateTime(2026, 1, 2), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), + ], + ), + ]; + + expect( + const DiveFilterState(decoOnly: true).apply(dives).map((d) => d.id), + ['deco'], + ); + expect( + const DiveFilterState(decoOnly: false).apply(dives).map((d) => d.id), + ['noDeco'], + ); + }); +} diff --git a/test/features/dive_log/domain/models/dive_filter_state_test.dart b/test/features/dive_log/domain/models/dive_filter_state_test.dart index 74328ef5a8..103d548d2f 100644 --- a/test/features/dive_log/domain/models/dive_filter_state_test.dart +++ b/test/features/dive_log/domain/models/dive_filter_state_test.dart @@ -22,6 +22,7 @@ Dive _makeDive({ String? tripId, List customFields = const [], List equipment = const [], + List profile = const [], }) { return Dive( id: id, @@ -35,7 +36,7 @@ Dive _makeDive({ bottomTime: duration, tripId: tripId, tanks: const [], - profile: const [], + profile: profile, equipment: equipment, notes: '', photoIds: const [], @@ -75,6 +76,7 @@ void main() { expect(filter.minDepth, isNull); expect(filter.maxDepth, isNull); expect(filter.favoritesOnly, isNull); + expect(filter.decoOnly, isNull); expect(filter.tagIds, isEmpty); expect(filter.equipmentIds, isEmpty); expect(filter.buddyNameFilter, isNull); @@ -152,6 +154,18 @@ void main() { expect(filter.hasActiveFilters, isFalse); }); + test('returns true when decoOnly is true', () { + const filter = DiveFilterState(decoOnly: true); + + expect(filter.hasActiveFilters, isTrue); + }); + + test('returns true when decoOnly is false', () { + const filter = DiveFilterState(decoOnly: false); + + expect(filter.hasActiveFilters, isTrue); + }); + test('returns true when diveIds is non-empty', () { const filter = DiveFilterState(diveIds: ['d1', 'd2']); @@ -208,6 +222,22 @@ void main() { expect(updated.computerId, isNull); }); + test('sets decoOnly', () { + const original = DiveFilterState(); + + final updated = original.copyWith(decoOnly: true); + + expect(updated.decoOnly, isTrue); + }); + + test('clears decoOnly with clearDecoOnly', () { + const original = DiveFilterState(decoOnly: false); + + final updated = original.copyWith(clearDecoOnly: true); + + expect(updated.decoOnly, isNull); + }); + test('sets and clears multiple fields simultaneously', () { const original = DiveFilterState( minRating: 3, @@ -618,6 +648,91 @@ void main() { expect(result.map((d) => d.id), containsAll(['d1', 'd2'])); }); + group('decoOnly axis', () { + test('decoOnly: true matches a dive with a recorded deco stop', () { + const filter = DiveFilterState(decoOnly: true); + final dives = [ + _makeDive( + id: 'd1', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 30, decoType: 0), + DiveProfilePoint(timestamp: 60, depth: 30, decoType: 2), + ], + ), + _makeDive( + id: 'd2', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 20, decoType: 0), + ], + ), + ]; + + expect(filter.apply(dives).map((d) => d.id), ['d1']); + }); + + test( + 'decoOnly: true matches a positive ceiling with no deco_type data', + () { + const filter = DiveFilterState(decoOnly: true); + final dives = [ + _makeDive( + id: 'd1', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 30, ceiling: 3), + ], + ), + ]; + + expect(filter.apply(dives).map((d) => d.id), ['d1']); + }, + ); + + test( + 'decoOnly: false matches a profile with deco_type but no stop', + () { + const filter = DiveFilterState(decoOnly: false); + final dives = [ + _makeDive( + id: 'd1', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 20, decoType: 0), + ], + ), + _makeDive( + id: 'd2', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), + ], + ), + ]; + + expect(filter.apply(dives).map((d) => d.id), ['d1']); + }, + ); + + test('decoOnly excludes dives with no profile data either way', () { + final dives = [_makeDive(id: 'd1')]; + + expect(const DiveFilterState(decoOnly: true).apply(dives), isEmpty); + expect(const DiveFilterState(decoOnly: false).apply(dives), isEmpty); + }); + + test('decoOnly null applies no deco filtering', () { + const filter = DiveFilterState(); + final dives = [ + _makeDive(id: 'd1'), + _makeDive( + id: 'd2', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), + ], + ), + ]; + + expect(filter.apply(dives), hasLength(2)); + }); + }); + group('equipmentAttr axis', () { EquipmentAttribute curated(String key, {String? text, double? num}) => EquipmentAttribute.curated( diff --git a/test/features/statistics/data/dive_filter_sql_test.dart b/test/features/statistics/data/dive_filter_sql_test.dart index e65720fa49..7abb4b5966 100644 --- a/test/features/statistics/data/dive_filter_sql_test.dart +++ b/test/features/statistics/data/dive_filter_sql_test.dart @@ -225,6 +225,46 @@ void main() { ); } + Future insertProfilePoint( + String diveId, + String id, { + int timestamp = 0, + double depth = 30, + int? decoType, + double? ceiling, + }) async { + await db + .into(db.diveProfiles) + .insert( + DiveProfilesCompanion( + id: Value(id), + diveId: Value(diveId), + timestamp: Value(timestamp), + depth: Value(depth), + decoType: Value(decoType), + ceiling: Value(ceiling), + ), + ); + } + + Future insertProfileEvent( + String diveId, + String id, { + String eventType = 'decoStopStart', + }) async { + await db + .into(db.diveProfileEvents) + .insert( + DiveProfileEventsCompanion( + id: Value(id), + diveId: Value(diveId), + timestamp: const Value(0), + eventType: Value(eventType), + createdAt: Value(now), + ), + ); + } + Future insertCustomField( String diveId, String key, @@ -306,6 +346,37 @@ void main() { }); }); + test( + 'decoOnly axis: recorded signal (stop, no-stop, ceiling-only, event-only, ' + 'unrecorded)', + () async { + await insertDive('stop'); // deco: a deco_type = 2 point + await insertDive('noStop'); // no-deco: has deco_type, none is 2 + await insertDive('ceilingOnly'); // deco: positive ceiling, no deco_type + await insertDive('eventOnly'); // deco: decoStopStart event only + await insertDive('none'); // unrecorded: no profile data at all + + await insertProfilePoint('stop', 'p-stop-1', decoType: 0); + await insertProfilePoint('stop', 'p-stop-2', decoType: 2); + + await insertProfilePoint('noStop', 'p-noStop-1', decoType: 0); + + await insertProfilePoint('ceilingOnly', 'p-ceiling-1', ceiling: 3.0); + + await insertProfilePoint('eventOnly', 'p-event-1'); + await insertProfileEvent('eventOnly', 'e-event-1'); + + expect(await idsMatching(const DiveFilterState(decoOnly: true)), { + 'stop', + 'ceilingOnly', + 'eventOnly', + }); + expect(await idsMatching(const DiveFilterState(decoOnly: false)), { + 'noStop', + }); + }, + ); + test( 'bottom-time filter truncates to whole minutes like Duration.inMinutes', () async { From bd29366fe3723b723dcf81bd227a3b41bf7b2e96 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:48:22 +0000 Subject: [PATCH 002/122] Add dive-count sort and favorites to the Add Buddy picker Buddies in the "Add buddy" sheet can now be sorted by number of shared dives (descending by default) instead of just alphabetically, and can be marked as favorites with a star toggle that pins them to the top of the list regardless of sort. Adds buddies.is_favorite (schema v161) with the usual onUpgrade/beforeOpen migration pair. Addresses submersion-app/submersion#638. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- lib/core/database/database.dart | 33 +++- .../repositories/buddy_merge_repository.dart | 3 + .../data/repositories/buddy_repository.dart | 95 +++++++++++- .../buddies/domain/entities/buddy.dart | 5 + .../presentation/pages/buddy_edit_page.dart | 4 + .../providers/buddy_providers.dart | 72 +++++++-- .../presentation/widgets/buddy_picker.dart | 146 ++++++++++++++++-- .../migration_v161_buddy_favorite_test.dart | 113 ++++++++++++++ .../repositories/buddy_repository_test.dart | 88 +++++++++++ .../providers/buddy_providers_test.dart | 116 ++++++++++++++ .../buddy_picker_chip_interactions_test.dart | 10 +- .../widgets/buddy_picker_roles_test.dart | 22 ++- .../widgets/buddy_picker_test.dart | 138 +++++++++++------ 13 files changed, 756 insertions(+), 89 deletions(-) create mode 100644 test/core/database/migration_v161_buddy_favorite_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 964f389169..bf1339ceb4 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1874,6 +1874,7 @@ class Buddies extends Table { TextColumn get phone => text().nullable()(); TextColumn get photoPath => text().nullable()(); TextColumn get notes => text().withDefault(const Constant(''))(); + BoolColumn get isFavorite => boolean().withDefault(const Constant(false))(); IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); @@ -3162,7 +3163,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 = 160; + static const int currentSchemaVersion = 161; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3444,6 +3445,9 @@ class AppDatabase extends _$AppDatabase { // service_records.service_type -> service_category rename. Renumbered // from 158 and then 159, which #1149 and #1177 claimed first on main. 160, + // v161 (issue #638): buddies.is_favorite, so frequently-dived buddies can + // be pinned to the top of the "Add buddy" picker regardless of sort. + 161, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4962,6 +4966,21 @@ class AppDatabase extends _$AppDatabase { } } + /// Idempotent DDL for the v161 buddies.is_favorite column (issue #638), + /// letting frequently-dived buddies be pinned to the top of the "Add + /// buddy" picker regardless of sort. Self-guards on the table existing, and + /// defaults every pre-existing row to not-favorited. + Future _assertBuddyFavoriteColumn() async { + final cols = await customSelect("PRAGMA table_info('buddies')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('is_favorite')) { + await customStatement( + 'ALTER TABLE buddies ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0', + ); + } + } + /// Idempotent DDL for the v159 dive_data_sources.time_offset_seconds /// column (issue #1177). Same dual-call contract (onUpgrade + beforeOpen /// backstop) as the other column-assert helpers. Nullable with no default, @@ -8506,6 +8525,13 @@ class AppDatabase extends _$AppDatabase { await _assertServiceCategoryRename(); } if (from < 160) await reportProgress(); + // v161 (issue #638): buddies.is_favorite, so frequently-dived buddies + // can be pinned to the top of the "Add buddy" picker regardless of + // sort. + if (from < 161) { + await _assertBuddyFavoriteColumn(); + } + if (from < 161) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -8696,6 +8722,11 @@ class AppDatabase extends _$AppDatabase { // onUpgrade, and every read of a service record would throw. await _assertServiceCategoryRename(); + // v161 backstop: re-assert buddies.is_favorite (issue #638). A + // database that arrives by restore or sync-adopt never runs + // onUpgrade, and every read of a buddy would throw without it. + await _assertBuddyFavoriteColumn(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); diff --git a/lib/features/buddies/data/repositories/buddy_merge_repository.dart b/lib/features/buddies/data/repositories/buddy_merge_repository.dart index 79b5b4523e..4439ec81f9 100644 --- a/lib/features/buddies/data/repositories/buddy_merge_repository.dart +++ b/lib/features/buddies/data/repositories/buddy_merge_repository.dart @@ -114,6 +114,7 @@ class BuddyMergeRepository { certificationAgency: null, photoPath: row.photoPath, notes: row.notes, + isFavorite: row.isFavorite, createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt), ); @@ -444,6 +445,7 @@ class BuddyMergeRepository { phone: Value(buddy.phone), photoPath: Value(buddy.photoPath), notes: Value(buddy.notes), + isFavorite: Value(buddy.isFavorite), createdAt: Value(buddy.createdAt.millisecondsSinceEpoch), updatedAt: Value(buddy.updatedAt.millisecondsSinceEpoch), ), @@ -591,6 +593,7 @@ class BuddyMergeRepository { phone: Value(buddy.phone), photoPath: Value(buddy.photoPath), notes: Value(buddy.notes), + isFavorite: Value(buddy.isFavorite), updatedAt: Value(now), ), ); diff --git a/lib/features/buddies/data/repositories/buddy_repository.dart b/lib/features/buddies/data/repositories/buddy_repository.dart index 35c5132027..2f6a10b421 100644 --- a/lib/features/buddies/data/repositories/buddy_repository.dart +++ b/lib/features/buddies/data/repositories/buddy_repository.dart @@ -146,6 +146,7 @@ class BuddyRepository { ), photoPath: row.data['photo_path'] as String?, notes: (row.data['notes'] as String?) ?? '', + isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1, createdAt: DateTime.fromMillisecondsSinceEpoch( row.data['created_at'] as int, ), @@ -175,6 +176,7 @@ class BuddyRepository { phone: Value(buddy.phone), photoPath: Value(buddy.photoPath), notes: Value(buddy.notes), + isFavorite: Value(buddy.isFavorite), createdAt: Value(now.millisecondsSinceEpoch), updatedAt: Value(now.millisecondsSinceEpoch), ), @@ -236,6 +238,7 @@ class BuddyRepository { ), photoPath: row.data['photo_path'] as String?, notes: (row.data['notes'] as String?) ?? '', + isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1, createdAt: DateTime.fromMillisecondsSinceEpoch( row.data['created_at'] as int, ), @@ -283,6 +286,7 @@ class BuddyRepository { phone: Value(buddy.phone), photoPath: Value(buddy.photoPath), notes: Value(buddy.notes), + isFavorite: Value(buddy.isFavorite), updatedAt: Value(now), ), ); @@ -375,6 +379,7 @@ class BuddyRepository { ), photoPath: row.data['photo_path'] as String?, notes: (row.data['notes'] as String?) ?? '', + isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1, createdAt: DateTime.fromMillisecondsSinceEpoch( row.data['created_at'] as int, ), @@ -436,6 +441,7 @@ class BuddyRepository { phone: b.phone, photoPath: b.photoPath, notes: b.notes, + isFavorite: b.isFavorite, createdAt: DateTime.fromMillisecondsSinceEpoch(b.createdAt), updatedAt: DateTime.fromMillisecondsSinceEpoch(b.updatedAt), ); @@ -712,13 +718,35 @@ class BuddyRepository { } } - /// Get all buddies with their dive counts in a single efficient query + /// Get all buddies with their dive counts in a single efficient query. + /// + /// [query] optionally filters by name/email/phone (case-insensitive), for + /// the "Add buddy" picker's search box, which needs dive counts too so it + /// can sort search results the same way as the unfiltered list. Future> getAllBuddiesWithDiveCount({ String? diverId, + String? query, }) async { try { - final diverFilter = diverId != null ? 'WHERE b.diver_id = ?' : ''; - final variables = [if (diverId != null) Variable.withString(diverId)]; + final conditions = [ + if (diverId != null) 'b.diver_id = ?', + if (query != null && query.isNotEmpty) + '(LOWER(b.name) LIKE ? OR LOWER(b.email) LIKE ? OR b.phone LIKE ?)', + ]; + final where = conditions.isEmpty + ? '' + : 'WHERE ${conditions.join(' AND ')}'; + final searchTerm = query != null && query.isNotEmpty + ? '%${query.toLowerCase()}%' + : null; + final variables = [ + if (diverId != null) Variable.withString(diverId), + if (searchTerm != null) ...[ + Variable.withString(searchTerm), + Variable.withString(searchTerm), + Variable.withString(searchTerm), + ], + ]; final results = await _db.customSelect(''' SELECT b.*, COALESCE(dc.dive_count, 0) as dive_count @@ -728,7 +756,7 @@ class BuddyRepository { FROM dive_buddies GROUP BY buddy_id ) dc ON b.id = dc.buddy_id - $diverFilter + $where ORDER BY b.name ASC ''', variables: variables).get(); @@ -747,6 +775,7 @@ class BuddyRepository { ), photoPath: row.data['photo_path'] as String?, notes: (row.data['notes'] as String?) ?? '', + isFavorite: (row.data['is_favorite'] as int? ?? 0) == 1, createdAt: DateTime.fromMillisecondsSinceEpoch( row.data['created_at'] as int, ), @@ -775,6 +804,63 @@ class BuddyRepository { } } + /// Toggle favorite status for a buddy + Future toggleFavorite(String buddyId) async { + try { + _log.info('Toggling favorite for buddy: $buddyId'); + final now = DateTime.now().millisecondsSinceEpoch; + final buddy = await (_db.select( + _db.buddies, + )..where((t) => t.id.equals(buddyId))).getSingleOrNull(); + if (buddy == null) return; + await (_db.update(_db.buddies)..where((t) => t.id.equals(buddyId))).write( + BuddiesCompanion( + isFavorite: Value(!buddy.isFavorite), + updatedAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'buddies', + recordId: buddyId, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + _log.info('Toggled favorite for buddy: $buddyId'); + } catch (e, stackTrace) { + _log.error( + 'Failed to toggle favorite for buddy: $buddyId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + /// Set favorite status for a buddy + Future setFavorite(String buddyId, bool isFavorite) async { + try { + _log.info('Setting favorite=$isFavorite for buddy: $buddyId'); + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update(_db.buddies)..where((t) => t.id.equals(buddyId))).write( + BuddiesCompanion(isFavorite: Value(isFavorite), updatedAt: Value(now)), + ); + await _syncRepository.markRecordPending( + entityType: 'buddies', + recordId: buddyId, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + _log.info('Set favorite=$isFavorite for buddy: $buddyId'); + } catch (e, stackTrace) { + _log.error( + 'Failed to set favorite for buddy: $buddyId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Get dive count for a buddy Future getDiveCountForBuddy(String buddyId) async { final result = await _db @@ -928,6 +1014,7 @@ class BuddyRepository { certificationAgency: null, photoPath: row.photoPath, notes: row.notes, + isFavorite: row.isFavorite, createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt), ); diff --git a/lib/features/buddies/domain/entities/buddy.dart b/lib/features/buddies/domain/entities/buddy.dart index df792bdf7f..9ae2701d5e 100644 --- a/lib/features/buddies/domain/entities/buddy.dart +++ b/lib/features/buddies/domain/entities/buddy.dart @@ -14,6 +14,7 @@ class Buddy extends Equatable { final CertificationAgency? certificationAgency; final String? photoPath; final String notes; + final bool isFavorite; final DateTime createdAt; final DateTime updatedAt; @@ -27,6 +28,7 @@ class Buddy extends Equatable { this.certificationAgency, this.photoPath, this.notes = '', + this.isFavorite = false, required this.createdAt, required this.updatedAt, }); @@ -65,6 +67,7 @@ class Buddy extends Equatable { CertificationAgency? certificationAgency, String? photoPath, String? notes, + bool? isFavorite, DateTime? createdAt, DateTime? updatedAt, }) { @@ -78,6 +81,7 @@ class Buddy extends Equatable { certificationAgency: certificationAgency ?? this.certificationAgency, photoPath: photoPath ?? this.photoPath, notes: notes ?? this.notes, + isFavorite: isFavorite ?? this.isFavorite, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); @@ -94,6 +98,7 @@ class Buddy extends Equatable { certificationAgency, photoPath, notes, + isFavorite, createdAt, updatedAt, ]; diff --git a/lib/features/buddies/presentation/pages/buddy_edit_page.dart b/lib/features/buddies/presentation/pages/buddy_edit_page.dart index de7207407c..a60b55e414 100644 --- a/lib/features/buddies/presentation/pages/buddy_edit_page.dart +++ b/lib/features/buddies/presentation/pages/buddy_edit_page.dart @@ -702,6 +702,10 @@ class _BuddyEditPageState extends ConsumerState { ? _mergeCtrl?.mergedPhotoPath : _originalBuddy?.photoPath, notes: _notesController.text.trim(), + // Preserve favorite status (issue #638): this form has no favorite + // control, so a full-constructor rebuild would otherwise silently + // reset it to false on every save. + isFavorite: _originalBuddy?.isFavorite ?? false, createdAt: _originalBuddy?.createdAt ?? now, updatedAt: now, ); diff --git a/lib/features/buddies/presentation/providers/buddy_providers.dart b/lib/features/buddies/presentation/providers/buddy_providers.dart index 9580f26d9a..b86bf4d126 100644 --- a/lib/features/buddies/presentation/providers/buddy_providers.dart +++ b/lib/features/buddies/presentation/providers/buddy_providers.dart @@ -60,6 +60,37 @@ final allBuddiesWithDiveCountProvider = return repository.getAllBuddiesWithDiveCount(diverId: validatedDiverId); }); +/// Search results with dive counts, for the "Add buddy" picker sheet, which +/// sorts by dive count and needs that even while a search query is active. +final buddySearchWithDiveCountProvider = + FutureProvider.family, String>(( + ref, + query, + ) async { + if (query.isEmpty) { + return ref.watch(allBuddiesWithDiveCountProvider).value ?? []; + } + final repository = ref.watch(buddyRepositoryProvider); + final validatedDiverId = await ref.watch( + validatedCurrentDiverIdProvider.future, + ); + ref.invalidateSelfWhen(repository.watchBuddiesChanges()); + return repository.getAllBuddiesWithDiveCount( + diverId: validatedDiverId, + query: query, + ); + }); + +/// Sort state for the "Add buddy" picker sheet. Defaults to dive count +/// descending (issue #638): divers with many buddies on file mostly care +/// about who they dive with often, not the full alphabet. +final buddyPickerSortProvider = StateProvider>( + (ref) => const SortState( + field: BuddySortField.diveCount, + direction: SortDirection.descending, + ), +); + /// Apply sorting to a list of buddies with dive counts List applyBuddyWithDiveCountSorting( List buddies, @@ -67,26 +98,29 @@ List applyBuddyWithDiveCountSorting( ) { final sorted = List.from(buddies); - sorted.sort((a, b) { - int comparison; - // For text fields, invert direction (user expects descending = A→Z) - final invertForText = sort.field == BuddySortField.name; + int byNameAscending(BuddyWithDiveCount a, BuddyWithDiveCount b) => + a.buddy.name.toLowerCase().compareTo(b.buddy.name.toLowerCase()); + sorted.sort((a, b) { switch (sort.field) { case BuddySortField.name: - comparison = a.buddy.name.toLowerCase().compareTo( - b.buddy.name.toLowerCase(), - ); + final comparison = byNameAscending(a, b); + // For text fields, invert direction (user expects descending = A→Z) + return sort.direction == SortDirection.ascending + ? -comparison + : comparison; case BuddySortField.diveCount: - comparison = a.diveCount.compareTo(b.diveCount); + final comparison = a.diveCount.compareTo(b.diveCount); + if (comparison == 0) { + // Ties (very common -- most buddies share 0 dives) break + // alphabetically, so the order is deterministic instead of left to + // an unstable sort. + return byNameAscending(a, b); + } + return sort.direction == SortDirection.ascending + ? comparison + : -comparison; } - - if (invertForText) { - return sort.direction == SortDirection.ascending - ? -comparison - : comparison; - } - return sort.direction == SortDirection.ascending ? comparison : -comparison; }); return sorted; @@ -312,6 +346,14 @@ class BuddyListNotifier extends StateNotifier>> { await refresh(); } + /// Toggle favorite status for a buddy (issue #638) + Future toggleFavorite(String buddyId) async { + await _repository.toggleFavorite(buddyId); + _ref.invalidate(buddyByIdProvider(buddyId)); + _ref.invalidate(allBuddiesWithDiveCountProvider); + await refresh(); + } + Future mergeBuddies( Buddy mergedBuddy, List buddyIds, diff --git a/lib/features/buddies/presentation/widgets/buddy_picker.dart b/lib/features/buddies/presentation/widgets/buddy_picker.dart index a997e18598..241c121b6b 100644 --- a/lib/features/buddies/presentation/widgets/buddy_picker.dart +++ b/lib/features/buddies/presentation/widgets/buddy_picker.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/constants/sort_options.dart'; +import 'package:submersion/core/constants/sort_options_display.dart'; +import 'package:submersion/core/models/sort_state.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:go_router/go_router.dart'; @@ -9,6 +12,8 @@ import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/features/dive_roles/domain/entities/dive_role.dart'; import 'package:submersion/features/dive_roles/presentation/dive_role_display.dart'; import 'package:submersion/features/dive_roles/presentation/providers/dive_role_providers.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart' + show BuddyWithDiveCount; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/certifications/domain/entities/certification.dart'; @@ -311,7 +316,7 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { String _searchQuery = ''; String _debouncedQuery = ''; Timer? _debounceTimer; - List? _lastSearchResults; + List? _lastSearchResults; late List _localSelectedBuddies; Map> _certsByBuddy = const >{}; @@ -345,8 +350,9 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { @override Widget build(BuildContext context) { final buddiesAsync = _debouncedQuery.isEmpty - ? ref.watch(allBuddiesProvider) - : ref.watch(buddySearchProvider(_debouncedQuery)); + ? ref.watch(allBuddiesWithDiveCountProvider) + : ref.watch(buddySearchWithDiveCountProvider(_debouncedQuery)); + final sort = ref.watch(buddyPickerSortProvider); return DraggableScrollableSheet( initialChildSize: 0.7, @@ -435,6 +441,7 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { if (result != null) { // New buddy was created, refresh the list so they can select it ref.invalidate(allBuddiesProvider); + ref.invalidate(allBuddiesWithDiveCountProvider); } }, icon: const Icon(Icons.person_add), @@ -444,8 +451,37 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { ), ), ), - const SizedBox(height: 8), - const Divider(), + const SizedBox(height: 4), + + // Sort toggle (issue #638): alternates between dive-count-desc + // (the default -- who do I dive with most) and alphabetical. + // Favorites are pinned to the top regardless of this choice. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () { + final next = sort.field == BuddySortField.diveCount + ? const SortState( + field: BuddySortField.name, + direction: SortDirection.ascending, + ) + : const SortState( + field: BuddySortField.diveCount, + direction: SortDirection.descending, + ); + ref.read(buddyPickerSortProvider.notifier).state = next; + }, + icon: Icon(sort.field.icon, size: 18), + label: Text( + '${context.l10n.buddies_action_sort}: ' + '${sort.field.localizedName(context.l10n)}', + ), + ), + ), + ), + const Divider(height: 1), // Buddy list Expanded( @@ -484,6 +520,7 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { scrollController, buddies, _certsByBuddy, + sort, ); }, loading: () { @@ -497,6 +534,7 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { scrollController, _lastSearchResults!, _certsByBuddy, + sort, ), ), ], @@ -517,14 +555,51 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { Widget _buildBuddyListView( ScrollController scrollController, - List buddies, + List buddies, Map> certsByBuddy, + SortState sort, ) { + // Favorites are pinned to the top regardless of the chosen sort field + // (issue #638); each partition is sorted independently so the toggle + // still reorders within both groups. + final favorites = applyBuddyWithDiveCountSorting( + buddies.where((b) => b.buddy.isFavorite).toList(), + sort, + ); + final others = applyBuddyWithDiveCountSorting( + buddies.where((b) => !b.buddy.isFavorite).toList(), + sort, + ); + + final rows = <_PickerRow>[ + if (favorites.isNotEmpty) + _PickerRow.header(context.l10n.diveLog_filterChip_favorites), + ...favorites.map(_PickerRow.entry), + if (favorites.isNotEmpty && others.isNotEmpty) const _PickerRow.divider(), + ...others.map(_PickerRow.entry), + ]; + return ListView.builder( controller: scrollController, - itemCount: buddies.length, + itemCount: rows.length, itemBuilder: (context, index) { - final buddy = buddies[index]; + final row = rows[index]; + if (row.isDivider) return const Divider(height: 1); + if (row.header != null) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + row.header!, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + final buddy = row.entry!.buddy; + final diveCount = row.entry!.diveCount; final isSelected = _localSelectedBuddies.any( (b) => b.buddy.id == buddy.id, ); @@ -555,15 +630,45 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { subtitle: buddy.certificationLevel == null ? null : Text(buddy.certificationLevel!.displayName), - trailing: isSelected - ? Chip( + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (diveCount > 0) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + context.l10n.buddies_label_diveCount(diveCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + IconButton( + icon: Icon( + buddy.isFavorite ? Icons.star : Icons.star_border, + size: 20, + color: buddy.isFavorite + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + tooltip: buddy.isFavorite + ? context.l10n.diveLog_detail_tooltip_removeFromFavorites + : context.l10n.diveLog_detail_tooltip_addToFavorites, + visualDensity: VisualDensity.compact, + onPressed: () => ref + .read(buddyListNotifierProvider.notifier) + .toggleFavorite(buddy.id), + ), + if (isSelected) + Chip( label: Text( selectedRole?.localizedName(context.l10n) ?? context.l10n.diveRole_builtin_buddy, ), visualDensity: VisualDensity.compact, - ) - : null, + ), + ], + ), onTap: () { if (isSelected) { _removeBuddy(buddy.id); @@ -663,3 +768,20 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { } } } + +/// A single row in the Add-buddy list: a section header, a divider between +/// the favorites section and the rest, or a buddy entry. +class _PickerRow { + final String? header; + final bool isDivider; + final BuddyWithDiveCount? entry; + + const _PickerRow.header(this.header) : isDivider = false, entry = null; + + const _PickerRow.divider() : header = null, isDivider = true, entry = null; + + const _PickerRow.entry(BuddyWithDiveCount value) + : header = null, + isDivider = false, + entry = value; +} diff --git a/test/core/database/migration_v161_buddy_favorite_test.dart b/test/core/database/migration_v161_buddy_favorite_test.dart new file mode 100644 index 0000000000..5e46fec2ad --- /dev/null +++ b/test/core/database/migration_v161_buddy_favorite_test.dart @@ -0,0 +1,113 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +/// v161 adds `buddies.is_favorite` (issue #638): a diver can pin frequently +/// dived buddies to the top of the "Add buddy" picker regardless of the +/// chosen sort. NOT NULL with a false default, so every pre-existing buddy +/// reads back as not-favorited. +void main() { + test('v161 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(161)); + expect(AppDatabase.migrationVersions, contains(161)); + }); + + test('a fresh database has buddies.is_favorite', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('buddies')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('is_favorite')); + }); + + test('the column is NOT NULL with a false default', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('buddies')").get(); + final column = cols.firstWhere( + (c) => c.read('name') == 'is_favorite', + ); + expect(column.read('notnull'), 1); + expect(column.read('dflt_value'), '0'); + }); + + test( + 'a database stranded at v160 gains the column via onUpgrade and ' + 'existing rows default to not-favorited', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 160'); + rawDb.execute(''' + CREATE TABLE buddies ( + id TEXT NOT NULL PRIMARY KEY, diver_id TEXT, name TEXT NOT NULL, + email TEXT, phone TEXT, photo_path TEXT, + notes TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, hlc TEXT) + '''); + rawDb.execute( + "INSERT INTO buddies (id, name, created_at, updated_at) " + "VALUES ('b1', 'B1', 0, 0)", + ); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('buddies')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('is_favorite')); + + final row = await db + .customSelect("SELECT is_favorite FROM buddies WHERE id = 'b1'") + .getSingle(); + expect(row.read('is_favorite'), 0); + }, + ); + + test( + 'beforeOpen backstop adds the column when a parallel-branch collision ' + 'stranded a DB past v161 without running the onUpgrade block', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute( + 'PRAGMA user_version = ${AppDatabase.currentSchemaVersion}', + ); + rawDb.execute(''' + CREATE TABLE buddies ( + id TEXT NOT NULL PRIMARY KEY, diver_id TEXT, name TEXT NOT NULL, + email TEXT, phone TEXT, photo_path TEXT, + notes TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, hlc TEXT) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('buddies')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('is_favorite')); + }, + ); + + test('the assert is a no-op when the buddies table is absent', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('CREATE TABLE unrelated (id TEXT)'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + await db.customSelect('SELECT 1').get(); + }); +} diff --git a/test/features/buddies/data/repositories/buddy_repository_test.dart b/test/features/buddies/data/repositories/buddy_repository_test.dart index 820b733aa3..218f4c2cb1 100644 --- a/test/features/buddies/data/repositories/buddy_repository_test.dart +++ b/test/features/buddies/data/repositories/buddy_repository_test.dart @@ -257,6 +257,94 @@ void main() { }); }); + group('getAllBuddiesWithDiveCount (issue #638)', () { + Future insertDive(String id) async { + final db = DatabaseService.instance.database; + await db.customStatement( + "INSERT INTO dives (id, dive_date_time, created_at, updated_at) " + "VALUES ('$id', 1000, 1000, 1000)", + ); + } + + test('reports the correct dive count per buddy', () async { + await insertDive('d1'); + await insertDive('d2'); + final frequent = await repository.createBuddy( + createTestBuddy(id: 'frequent', name: 'Frequent Buddy'), + ); + final rare = await repository.createBuddy( + createTestBuddy(id: 'rare', name: 'Rare Buddy'), + ); + await repository.addBuddyToDive('d1', frequent.id, DiveRole.buddyId); + await repository.addBuddyToDive('d2', frequent.id, DiveRole.buddyId); + await repository.addBuddyToDive('d1', rare.id, DiveRole.buddyId); + + final results = await repository.getAllBuddiesWithDiveCount(); + final byId = {for (final r in results) r.buddy.id: r.diveCount}; + + expect(byId['frequent'], equals(2)); + expect(byId['rare'], equals(1)); + }); + + test('carries the isFavorite flag through', () async { + await repository.createBuddy( + createTestBuddy(id: 'fav', name: 'Favorite Buddy'), + ); + await repository.toggleFavorite('fav'); + + final results = await repository.getAllBuddiesWithDiveCount(); + final fav = results.firstWhere((r) => r.buddy.id == 'fav'); + + expect(fav.buddy.isFavorite, isTrue); + }); + + test('query filters by name, matching the picker search box', () async { + await repository.createBuddy( + createTestBuddy(id: 'alice', name: 'Alice'), + ); + await repository.createBuddy(createTestBuddy(id: 'bob', name: 'Bob')); + + final results = await repository.getAllBuddiesWithDiveCount( + query: 'ali', + ); + + expect(results.map((r) => r.buddy.id), equals(['alice'])); + }); + }); + + group('favorites (issue #638)', () { + test('toggleFavorite flips false to true and back', () async { + final buddy = await repository.createBuddy( + createTestBuddy(name: 'Toggle Buddy'), + ); + expect(buddy.isFavorite, isFalse); + + await repository.toggleFavorite(buddy.id); + expect((await repository.getBuddyById(buddy.id))!.isFavorite, isTrue); + + await repository.toggleFavorite(buddy.id); + expect( + (await repository.getBuddyById(buddy.id))!.isFavorite, + isFalse, + ); + }); + + test('setFavorite sets the flag explicitly', () async { + final buddy = await repository.createBuddy( + createTestBuddy(name: 'Set Favorite Buddy'), + ); + + await repository.setFavorite(buddy.id, true); + expect((await repository.getBuddyById(buddy.id))!.isFavorite, isTrue); + + await repository.setFavorite(buddy.id, false); + expect( + (await repository.getBuddyById(buddy.id))!.isFavorite, + isFalse, + ); + }); + }); + group('getBuddyStats', () { test('should return stats with zero dives for new buddy', () async { final buddy = await repository.createBuddy( diff --git a/test/features/buddies/presentation/providers/buddy_providers_test.dart b/test/features/buddies/presentation/providers/buddy_providers_test.dart index 37a3bbe5bd..ea6ddda6eb 100644 --- a/test/features/buddies/presentation/providers/buddy_providers_test.dart +++ b/test/features/buddies/presentation/providers/buddy_providers_test.dart @@ -1,7 +1,9 @@ import 'package:drift/drift.dart' show Value; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/constants/sort_options.dart'; import 'package:submersion/core/database/database.dart' as db; +import 'package:submersion/core/models/sort_state.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/services/database_service.dart'; @@ -30,6 +32,17 @@ Buddy _makeBuddy({ ); } +BuddyWithDiveCount _withCount( + String name, { + int diveCount = 0, + bool isFavorite = false, +}) { + return BuddyWithDiveCount( + buddy: _makeBuddy(id: name, name: name).copyWith(isFavorite: isFavorite), + diveCount: diveCount, + ); +} + /// Inserts a dive row directly into the `dives` table, mirroring a sync apply /// that writes rows without going through any list notifier. This fires the /// `dives` table-change tick that count-aware providers subscribe to. @@ -219,5 +232,108 @@ void main() { 'without any manual refresh() call', ); }); + + test('toggleFavorite flips the flag and refreshes the list', () async { + final diver = await seedCurrentDiver(); + final buddy = await buddyRepo.createBuddy( + _makeBuddy(name: 'Fave Buddy', diverId: diver.id), + ); + + final container = makeContainer(); + addTearDown(container.dispose); + + await container.read(buddyListNotifierProvider.notifier).toggleFavorite( + buddy.id, + ); + + final updated = await buddyRepo.getBuddyById(buddy.id); + expect(updated!.isFavorite, isTrue); + }); + }); + + group('applyBuddyWithDiveCountSorting (issue #638)', () { + test('sorts by dive count descending by default', () { + final buddies = [ + _withCount('Low', diveCount: 1), + _withCount('High', diveCount: 10), + _withCount('Mid', diveCount: 5), + ]; + + final sorted = applyBuddyWithDiveCountSorting( + buddies, + const SortState( + field: BuddySortField.diveCount, + direction: SortDirection.descending, + ), + ); + + expect(sorted.map((b) => b.buddy.name), ['High', 'Mid', 'Low']); + }); + + test('dive count ascending reverses the order', () { + final buddies = [ + _withCount('Low', diveCount: 1), + _withCount('High', diveCount: 10), + _withCount('Mid', diveCount: 5), + ]; + + final sorted = applyBuddyWithDiveCountSorting( + buddies, + const SortState( + field: BuddySortField.diveCount, + direction: SortDirection.ascending, + ), + ); + + expect(sorted.map((b) => b.buddy.name), ['Low', 'Mid', 'High']); + }); + + test('name sort is alphabetical regardless of dive count', () { + final buddies = [ + _withCount('Charlie', diveCount: 99), + _withCount('Alice', diveCount: 0), + _withCount('Bob', diveCount: 50), + ]; + + final sorted = applyBuddyWithDiveCountSorting( + buddies, + const SortState( + field: BuddySortField.name, + direction: SortDirection.descending, + ), + ); + + expect(sorted.map((b) => b.buddy.name), ['Alice', 'Bob', 'Charlie']); + }); + + test('does not mutate the input list', () { + final buddies = [ + _withCount('Low', diveCount: 1), + _withCount('High', diveCount: 10), + ]; + final original = List.of(buddies); + + applyBuddyWithDiveCountSorting( + buddies, + const SortState( + field: BuddySortField.diveCount, + direction: SortDirection.descending, + ), + ); + + expect(buddies, original); + }); + }); + + group('buddyPickerSortProvider (issue #638)', () { + test('defaults to dive count descending, not alphabetical', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final sort = container.read(buddyPickerSortProvider); + + expect(sort.field, BuddySortField.diveCount); + expect(sort.direction, SortDirection.descending); + }); }); } diff --git a/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart b/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart index fbe5cda1b7..7f36e76c14 100644 --- a/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart' + show BuddyWithDiveCount; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/buddies/presentation/widgets/buddy_picker.dart'; @@ -45,8 +47,12 @@ Widget _buildPicker({ validatedCurrentDiverIdProvider.overrideWith( (ref) async => validatedDiverId, ), - allBuddiesProvider.overrideWith((ref) async => [_alice]), - buddySearchProvider.overrideWith((ref, q) async => const []), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => [BuddyWithDiveCount(buddy: _alice, diveCount: 0)], + ), + buddySearchWithDiveCountProvider.overrideWith( + (ref, q) async => const [], + ), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/features/buddies/presentation/widgets/buddy_picker_roles_test.dart b/test/features/buddies/presentation/widgets/buddy_picker_roles_test.dart index 05aba8931f..96a930916e 100644 --- a/test/features/buddies/presentation/widgets/buddy_picker_roles_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_picker_roles_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart' + show BuddyWithDiveCount; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/buddies/presentation/widgets/buddy_picker.dart'; @@ -27,8 +29,8 @@ final _testRoles = [ /// Buddy with a pre-hydrated instructor cert level -- in production this /// comes from `_withPrimaryCerts`, but this widget test overrides -/// `allBuddiesProvider` directly, bypassing the repository, so the fixture -/// must carry the derived field itself. +/// `allBuddiesWithDiveCountProvider` directly, bypassing the repository, so +/// the fixture must carry the derived field itself. final _instructorBuddy = Buddy( id: 'buddy-1', name: 'Alice Instructor', @@ -64,6 +66,10 @@ final _instructorCert = Certification( updatedAt: _now, ); +List _withCount(Iterable buddies) => [ + for (final b in buddies) BuddyWithDiveCount(buddy: b, diveCount: 0), +]; + /// Sets a tall screen so that bottom sheets and role selectors fit without /// overflow. void _useTallScreen(WidgetTester tester) { @@ -87,8 +93,8 @@ void main() { testApp( overrides: [ allDiveRolesProvider.overrideWith((ref) async => _testRoles), - allBuddiesProvider.overrideWith( - (ref) async => [_instructorBuddy, _plainBuddy], + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _withCount([_instructorBuddy, _plainBuddy]), ), allBuddyCertificationsProvider.overrideWith( (ref) async => { @@ -117,8 +123,8 @@ void main() { testApp( overrides: [ allDiveRolesProvider.overrideWith((ref) async => _testRoles), - allBuddiesProvider.overrideWith( - (ref) async => [_credentialedBuddy, _plainBuddy], + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _withCount([_credentialedBuddy, _plainBuddy]), ), allBuddyCertificationsProvider.overrideWith( (ref) async => { @@ -164,8 +170,8 @@ void main() { testApp( overrides: [ allDiveRolesProvider.overrideWith((ref) async => _testRoles), - allBuddiesProvider.overrideWith( - (ref) async => [_credentialedBuddy, _plainBuddy], + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _withCount([_credentialedBuddy, _plainBuddy]), ), allBuddyCertificationsProvider.overrideWith( (ref) async => { diff --git a/test/features/buddies/presentation/widgets/buddy_picker_test.dart b/test/features/buddies/presentation/widgets/buddy_picker_test.dart index 1c2e9a220e..98186b94a9 100644 --- a/test/features/buddies/presentation/widgets/buddy_picker_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_picker_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart' + show BuddyWithDiveCount; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/dive_roles/domain/entities/dive_role.dart'; import 'package:submersion/features/dive_roles/presentation/providers/dive_role_providers.dart'; @@ -37,6 +39,16 @@ final _testBuddies = [ Buddy(id: '3', name: 'Charlie Brown', createdAt: _now, updatedAt: _now), ]; +/// [_testBuddies] wrapped with a dive count of 0, matching what the picker +/// sheet's providers return (it sorts by dive count -- see issue #638). +final _testBuddiesWithCount = [ + for (final b in _testBuddies) BuddyWithDiveCount(buddy: b, diveCount: 0), +]; + +List _withCount(Iterable buddies) => [ + for (final b in buddies) BuddyWithDiveCount(buddy: b, diveCount: 0), +]; + Widget _buildPicker({ List selectedBuddies = const [], ValueChanged>? onChanged, @@ -83,8 +95,12 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), - buddySearchProvider.overrideWith((ref, q) async => []), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), + buddySearchWithDiveCountProvider.overrideWith( + (ref, q) async => [], + ), ], ), ); @@ -100,7 +116,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -115,13 +133,15 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), - buddySearchProvider.overrideWith((ref, query) async { - return _testBuddies - .where( - (b) => b.name.toLowerCase().contains(query.toLowerCase()), - ) - .toList(); + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), + buddySearchWithDiveCountProvider.overrideWith((ref, query) async { + return _withCount( + _testBuddies.where( + (b) => b.name.toLowerCase().contains(query.toLowerCase()), + ), + ); }), ], ), @@ -151,13 +171,15 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), - buddySearchProvider.overrideWith((ref, query) async { - return _testBuddies - .where( - (b) => b.name.toLowerCase().contains(query.toLowerCase()), - ) - .toList(); + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), + buddySearchWithDiveCountProvider.overrideWith((ref, query) async { + return _withCount( + _testBuddies.where( + (b) => b.name.toLowerCase().contains(query.toLowerCase()), + ), + ); }), ], ), @@ -187,13 +209,15 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), - buddySearchProvider.overrideWith((ref, query) async { - return _testBuddies - .where( - (b) => b.name.toLowerCase().contains(query.toLowerCase()), - ) - .toList(); + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), + buddySearchWithDiveCountProvider.overrideWith((ref, query) async { + return _withCount( + _testBuddies.where( + (b) => b.name.toLowerCase().contains(query.toLowerCase()), + ), + ); }), ], ), @@ -228,7 +252,9 @@ void main() { _buildPicker( selectedBuddies: [selectedBuddy], overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -244,7 +270,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -266,7 +294,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -296,7 +326,9 @@ void main() { _buildPicker( selectedBuddies: [selectedBuddy], overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -322,7 +354,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => []), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => [], + ), ], ), ); @@ -337,8 +371,12 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), - buddySearchProvider.overrideWith((ref, query) async => []), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), + buddySearchWithDiveCountProvider.overrideWith( + (ref, query) async => [], + ), ], ), ); @@ -357,7 +395,7 @@ void main() { testWidgets('shows loading spinner when provider is loading', ( tester, ) async { - final completer = Completer>(); + final completer = Completer>(); addTearDown(() { if (!completer.isCompleted) completer.complete([]); }); @@ -365,7 +403,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) => completer.future), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) => completer.future, + ), ], ), ); @@ -384,7 +424,7 @@ void main() { testWidgets('caches search results and shows LinearProgressIndicator ' 'during subsequent loading', (tester) async { var callCount = 0; - final secondSearchCompleter = Completer>(); + final secondSearchCompleter = Completer>(); addTearDown(() { if (!secondSearchCompleter.isCompleted) { secondSearchCompleter.complete([]); @@ -394,18 +434,20 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), - buddySearchProvider.overrideWith((ref, query) { + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), + buddySearchWithDiveCountProvider.overrideWith((ref, query) { callCount++; if (callCount <= 1) { // First search completes immediately return Future.value( - _testBuddies - .where( - (b) => - b.name.toLowerCase().contains(query.toLowerCase()), - ) - .toList(), + _withCount( + _testBuddies.where( + (b) => + b.name.toLowerCase().contains(query.toLowerCase()), + ), + ), ); } // Second search hangs in loading @@ -444,7 +486,9 @@ void main() { _buildPicker( onChanged: (buddies) => result = buddies, overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -457,12 +501,12 @@ void main() { await tester.tap(find.text('Instructor')); await tester.pumpAndSettle(); - // Tap "Done" -- it's a TextButton in the sheet header - // Find all TextButtons and tap the one inside the bottom sheet - // The "Done" button is rendered by the _BuddySelectionSheet header + // Tap "Done" -- it's the TextButton in the sheet header. The sheet + // also has a sort-toggle TextButton (issue #638), so disambiguate by + // label rather than by type alone. final doneButton = find.descendant( of: find.byType(DraggableScrollableSheet), - matching: find.byType(TextButton), + matching: find.widgetWithText(TextButton, 'Done'), ); await tester.tap(doneButton); await tester.pumpAndSettle(); From 7f6dc0495880a16139c8ed926d20ebe1c62e250b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:11:55 +0000 Subject: [PATCH 003/122] Apply dart format to dive_filter_state.dart and deco filter test Fixes the two formatting violations flagged by CI's dart format check; whitespace/line-wrap only, no behavior change. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- .../domain/models/dive_filter_state.dart | 1 + .../dive_repository_deco_filter_test.dart | 69 +++++++++---------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/lib/features/dive_log/domain/models/dive_filter_state.dart b/lib/features/dive_log/domain/models/dive_filter_state.dart index e19a079030..1e56f7f7f5 100644 --- a/lib/features/dive_log/domain/models/dive_filter_state.dart +++ b/lib/features/dive_log/domain/models/dive_filter_state.dart @@ -16,6 +16,7 @@ class DiveFilterState { final double? minDepth; final double? maxDepth; final bool? favoritesOnly; + /// Decompression status, from the recorded profile signal (deco stop type, /// deco-stop events, or a positive ceiling with no deco-type data at all — /// see `scanRecordedDecoSignals` in StatisticsRepository). Null means no diff --git a/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart b/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart index 957d45d1d0..78e6a22e1f 100644 --- a/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart +++ b/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart @@ -15,44 +15,41 @@ void main() { }); tearDown(() async => tearDownTestDatabase()); - test( - 'decoOnly: true matches a recorded deco stop, decoOnly: false matches ' - 'a recorded no-deco profile', - () async { - await repository.createDive( - domain.Dive( - id: 'deco', - dateTime: DateTime(2026, 1, 1), - profile: const [ - domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 0), - domain.DiveProfilePoint(timestamp: 60, depth: 30, decoType: 2), - ], - ), - ); - await repository.createDive( - domain.Dive( - id: 'noDeco', - dateTime: DateTime(2026, 1, 2), - profile: const [ - domain.DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), - ], - ), - ); - await repository.createDive( - domain.Dive(id: 'unrecorded', dateTime: DateTime(2026, 1, 3)), - ); + test('decoOnly: true matches a recorded deco stop, decoOnly: false matches ' + 'a recorded no-deco profile', () async { + await repository.createDive( + domain.Dive( + id: 'deco', + dateTime: DateTime(2026, 1, 1), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 0), + domain.DiveProfilePoint(timestamp: 60, depth: 30, decoType: 2), + ], + ), + ); + await repository.createDive( + domain.Dive( + id: 'noDeco', + dateTime: DateTime(2026, 1, 2), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), + ], + ), + ); + await repository.createDive( + domain.Dive(id: 'unrecorded', dateTime: DateTime(2026, 1, 3)), + ); - final decoResults = await repository.getDiveSummaries( - filter: const DiveFilterState(decoOnly: true), - ); - expect(decoResults.map((d) => d.id).toSet(), {'deco'}); + final decoResults = await repository.getDiveSummaries( + filter: const DiveFilterState(decoOnly: true), + ); + expect(decoResults.map((d) => d.id).toSet(), {'deco'}); - final noDecoResults = await repository.getDiveSummaries( - filter: const DiveFilterState(decoOnly: false), - ); - expect(noDecoResults.map((d) => d.id).toSet(), {'noDeco'}); - }, - ); + final noDecoResults = await repository.getDiveSummaries( + filter: const DiveFilterState(decoOnly: false), + ); + expect(noDecoResults.map((d) => d.id).toSet(), {'noDeco'}); + }); test('in-memory apply() agrees with the SQL path', () { final dives = [ From c266052dcff35a37ab314e4716e44f69f1288932 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:33:40 +0000 Subject: [PATCH 004/122] Add weekday filter to Advanced Filter, combinable with date range Adds a DiveFilterState.weekdays axis (DateTime.weekday numbering) that ANDs with startDate/endDate when both are set, mirroring every other filter axis. Implemented across the in-memory apply(), the dive-list SQL builder, and the statistics SQL builder via strftime('%w', ...) on the wall-clock-as-UTC dive_date_time column. A new WeekdayFilterSelector widget renders locale-aware weekday chips ordered by the diver's locale week start (Monday- or Sunday-first) via MaterialLocalizations.firstDayOfWeekIndex, wired into both the dive list's filter sheet and the Advanced Search page. Resolves submersion-app/submersion#1234. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- .../repositories/dive_repository_impl.dart | 14 +++ .../domain/models/dive_filter_state.dart | 14 +++ .../presentation/pages/dive_search_page.dart | 36 ++++++- .../widgets/dive_filter_sheet.dart | 32 ++++++ .../widgets/weekday_filter_selector.dart | 68 +++++++++++++ .../statistics/data/dive_filter_sql.dart | 13 +++ 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 | 6 ++ lib/l10n/arb/app_localizations_de.dart | 6 ++ lib/l10n/arb/app_localizations_en.dart | 6 ++ lib/l10n/arb/app_localizations_es.dart | 6 ++ lib/l10n/arb/app_localizations_fr.dart | 6 ++ lib/l10n/arb/app_localizations_he.dart | 6 ++ lib/l10n/arb/app_localizations_hu.dart | 6 ++ lib/l10n/arb/app_localizations_it.dart | 6 ++ lib/l10n/arb/app_localizations_nl.dart | 6 ++ lib/l10n/arb/app_localizations_pt.dart | 6 ++ lib/l10n/arb/app_localizations_zh.dart | 6 ++ lib/l10n/arb/app_nl.arb | 2 + lib/l10n/arb/app_pt.arb | 2 + lib/l10n/arb/app_zh.arb | 2 + .../dive_repository_weekday_filter_test.dart | 71 ++++++++++++++ .../domain/models/dive_filter_state_test.dart | 78 +++++++++++++++ .../widgets/weekday_filter_selector_test.dart | 97 +++++++++++++++++++ .../statistics/data/dive_filter_sql_test.dart | 41 ++++++++ 33 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 lib/features/dive_log/presentation/widgets/weekday_filter_selector.dart create mode 100644 test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart create mode 100644 test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart diff --git a/lib/features/dive_log/data/repositories/dive_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_repository_impl.dart index d697ebbca1..720d7a2dd5 100644 --- a/lib/features/dive_log/data/repositories/dive_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_repository_impl.dart @@ -2038,6 +2038,20 @@ class DiveRepository { args.add(Variable(tagId)); } } + if (filter.weekdays.isNotEmpty) { + // d.dive_date_time is wall-clock-as-UTC epoch ms, so strftime('%w', ...) + // (0=Sunday..6=Saturday) already lines up with the wall-clock day. + // Converting DateTime.weekday (1=Monday..7=Sunday) via `% 7` matches + // that numbering, mirroring buildFilteredDiveIdSubquery. + final placeholders = List.filled(filter.weekdays.length, '?').join(', '); + clauses.add( + "CAST(strftime('%w', d.dive_date_time / 1000, 'unixepoch') AS INTEGER) " + 'IN ($placeholders)', + ); + for (final weekday in filter.weekdays) { + args.add(Variable(weekday % 7)); + } + } if (filter.equipmentIds.isNotEmpty) { final placeholders = List.filled( filter.equipmentIds.length, diff --git a/lib/features/dive_log/domain/models/dive_filter_state.dart b/lib/features/dive_log/domain/models/dive_filter_state.dart index 359df27d01..e543baf811 100644 --- a/lib/features/dive_log/domain/models/dive_filter_state.dart +++ b/lib/features/dive_log/domain/models/dive_filter_state.dart @@ -22,6 +22,12 @@ class DiveFilterState { final bool? noBuddyOnly; final List tagIds; + /// Restricts results to dives whose [Dive.dateTime] falls on one of these + /// weekdays, using [DateTime.weekday] numbering (1 = Monday, 7 = Sunday). + /// ANDs with [startDate]/[endDate] when both are set, like every other + /// axis in this filter. + final List weekdays; + // v1.5: Additional filter criteria final List equipmentIds; final String? buddyNameFilter; @@ -62,6 +68,7 @@ class DiveFilterState { this.favoritesOnly, this.noBuddyOnly, this.tagIds = const [], + this.weekdays = const [], this.equipmentIds = const [], this.buddyNameFilter, this.buddyId, @@ -92,6 +99,7 @@ class DiveFilterState { favoritesOnly == true || noBuddyOnly == true || tagIds.isNotEmpty || + weekdays.isNotEmpty || equipmentIds.isNotEmpty || (buddyNameFilter != null && buddyNameFilter!.isNotEmpty) || buddyId != null || @@ -117,6 +125,7 @@ class DiveFilterState { bool? favoritesOnly, bool? noBuddyOnly, List? tagIds, + List? weekdays, List? equipmentIds, String? buddyNameFilter, String? buddyId, @@ -144,6 +153,7 @@ class DiveFilterState { bool clearFavoritesOnly = false, bool clearNoBuddyOnly = false, bool clearTagIds = false, + bool clearWeekdays = false, bool clearEquipmentIds = false, bool clearBuddyNameFilter = false, bool clearBuddyId = false, @@ -174,6 +184,7 @@ class DiveFilterState { : (favoritesOnly ?? this.favoritesOnly), noBuddyOnly: clearNoBuddyOnly ? null : (noBuddyOnly ?? this.noBuddyOnly), tagIds: clearTagIds ? const [] : (tagIds ?? this.tagIds), + weekdays: clearWeekdays ? const [] : (weekdays ?? this.weekdays), equipmentIds: clearEquipmentIds ? const [] : (equipmentIds ?? this.equipmentIds), @@ -275,6 +286,9 @@ class DiveFilterState { return false; } } + if (weekdays.isNotEmpty && !weekdays.contains(dive.dateTime.weekday)) { + return false; + } if (buddyNameFilter != null && buddyNameFilter!.isNotEmpty) { final filters = buddyNameFilter! .split(',') diff --git a/lib/features/dive_log/presentation/pages/dive_search_page.dart b/lib/features/dive_log/presentation/pages/dive_search_page.dart index 181570b51c..f618a9ab5b 100644 --- a/lib/features/dive_log/presentation/pages/dive_search_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_search_page.dart @@ -12,6 +12,7 @@ import 'package:submersion/features/settings/presentation/providers/settings_pro import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/weekday_filter_selector.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/widgets/app_date_picker.dart'; @@ -44,6 +45,7 @@ class _DiveSearchPageState extends ConsumerState { // Date Range DateTime? _startDate; DateTime? _endDate; + List _selectedWeekdays = []; // Location String? _siteId; @@ -110,6 +112,7 @@ class _DiveSearchPageState extends ConsumerState { final filter = ref.read(_filterProvider); _startDate = filter.startDate; _endDate = filter.endDate; + _selectedWeekdays = List.from(filter.weekdays); _siteId = filter.siteId; _tripId = filter.tripId; _diveCenterId = filter.diveCenterId; @@ -138,7 +141,11 @@ class _DiveSearchPageState extends ConsumerState { _buddyNameController.text = _buddyNameFilter ?? ''; // Auto-expand sections with active filters - if (_startDate != null || _endDate != null) _expanded['date'] = true; + if (_startDate != null || + _endDate != null || + _selectedWeekdays.isNotEmpty) { + _expanded['date'] = true; + } if (_siteId != null || _tripId != null || _diveCenterId != null) { _expanded['location'] = true; } @@ -375,6 +382,31 @@ class _DiveSearchPageState extends ConsumerState { ), ), ], + const SizedBox(height: 16), + + // Weekdays. ANDs with the date range above: when both are set, only + // dives inside the range AND on one of these weekdays match. + Text( + context.l10n.diveLog_filter_sectionWeekdays, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 8), + WeekdayFilterSelector( + selectedWeekdays: _selectedWeekdays, + onChanged: (weekdays) { + setState(() => _selectedWeekdays = weekdays); + }, + ), + if (_selectedWeekdays.isNotEmpty) + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton( + onPressed: () { + setState(() => _selectedWeekdays = []); + }, + child: Text(context.l10n.diveLog_filter_clearWeekdays), + ), + ), ], ); } @@ -807,6 +839,7 @@ class _DiveSearchPageState extends ConsumerState { setState(() { _startDate = null; _endDate = null; + _selectedWeekdays = []; _siteId = null; _tripId = null; _diveCenterId = null; @@ -840,6 +873,7 @@ class _DiveSearchPageState extends ConsumerState { ref.read(_filterProvider.notifier).state = DiveFilterState( startDate: _startDate, endDate: _endDate, + weekdays: _selectedWeekdays, siteId: _siteId, tripId: _tripId, diveCenterId: _diveCenterId, diff --git a/lib/features/dive_log/presentation/widgets/dive_filter_sheet.dart b/lib/features/dive_log/presentation/widgets/dive_filter_sheet.dart index 9eb3937372..2fe0ec61c2 100644 --- a/lib/features/dive_log/presentation/widgets/dive_filter_sheet.dart +++ b/lib/features/dive_log/presentation/widgets/dive_filter_sheet.dart @@ -13,6 +13,7 @@ import 'package:submersion/features/settings/presentation/providers/settings_pro import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/weekday_filter_selector.dart'; import 'package:submersion/shared/widgets/app_date_picker.dart'; /// Filter sheet for dive list @@ -59,6 +60,7 @@ class _DiveFilterSheetState extends ConsumerState { late double? _maxDepth; late bool _favoritesOnly; late List _selectedTagIds; + late List _selectedWeekdays; // v1.5 filters late String? _buddyNameFilter; @@ -91,6 +93,7 @@ class _DiveFilterSheetState extends ConsumerState { _maxDepth = filter.maxDepth; _favoritesOnly = filter.favoritesOnly ?? false; _selectedTagIds = List.from(filter.tagIds); + _selectedWeekdays = List.from(filter.weekdays); // Depth bounds live in meters; the fields show and accept the diver's // configured depth unit. final units = UnitFormatter(widget.ref.read(settingsProvider)); @@ -328,6 +331,34 @@ class _DiveFilterSheetState extends ConsumerState { ), const SizedBox(height: 24), + // Weekday Section. ANDs with the date range above: when + // both are set, only dives inside the range AND on one + // of these weekdays match. + Text( + context.l10n.diveLog_filter_sectionWeekdays, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + WeekdayFilterSelector( + selectedWeekdays: _selectedWeekdays, + onChanged: (weekdays) { + setState(() => _selectedWeekdays = weekdays); + }, + ), + if (_selectedWeekdays.isNotEmpty) + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton( + onPressed: () { + setState(() => _selectedWeekdays = []); + }, + child: Text( + context.l10n.diveLog_filter_clearWeekdays, + ), + ), + ), + const SizedBox(height: 24), + // Dive Type Section Text( context.l10n.diveLog_filter_sectionDiveType, @@ -1131,6 +1162,7 @@ class _DiveFilterSheetState extends ConsumerState { maxDepth: _maxDepth, favoritesOnly: _favoritesOnly ? true : null, tagIds: _selectedTagIds, + weekdays: _selectedWeekdays, // v1.5 filters buddyNameFilter: _buddyNameFilter, noBuddyOnly: _noBuddyOnly ? true : null, diff --git a/lib/features/dive_log/presentation/widgets/weekday_filter_selector.dart b/lib/features/dive_log/presentation/widgets/weekday_filter_selector.dart new file mode 100644 index 0000000000..33376241c9 --- /dev/null +++ b/lib/features/dive_log/presentation/widgets/weekday_filter_selector.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A Monday, used only to derive locale-aware weekday labels via +/// [DateFormat]; the specific date is irrelevant, only its weekday is. +final DateTime _referenceMonday = DateTime(2024, 1, 1); + +/// Locale-aware abbreviated weekday label (e.g. "Mon", "lun.") for [weekday] +/// in [DateTime.weekday] numbering (1 = Monday, 7 = Sunday). +String weekdayAbbreviation(BuildContext context, int weekday) { + final locale = Localizations.localeOf(context).toString(); + return DateFormat.E( + locale, + ).format(_referenceMonday.add(Duration(days: weekday - 1))); +} + +/// Weekday chip selector for the dive list's Advanced Filter. +/// +/// Chips are ordered to match the diver's locale-specific week start +/// (Monday- or Sunday-first), derived from +/// [MaterialLocalizations.firstDayOfWeekIndex] the same way the platform +/// date picker orders its calendar grid. +class WeekdayFilterSelector extends StatelessWidget { + final List selectedWeekdays; + final ValueChanged> onChanged; + + const WeekdayFilterSelector({ + super.key, + required this.selectedWeekdays, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final materialLocalizations = MaterialLocalizations.of(context); + // firstDayOfWeekIndex: 0 = Sunday .. 6 = Saturday. Converted to + // DateTime.weekday numbering (1 = Monday .. 7 = Sunday) so the chip order + // follows the diver's locale-specific week start. + final firstWeekday = materialLocalizations.firstDayOfWeekIndex == 0 + ? 7 + : materialLocalizations.firstDayOfWeekIndex; + final orderedWeekdays = List.generate( + 7, + (i) => ((firstWeekday - 1 + i) % 7) + 1, + ); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: orderedWeekdays.map((weekday) { + final isSelected = selectedWeekdays.contains(weekday); + return FilterChip( + label: Text(weekdayAbbreviation(context, weekday)), + selected: isSelected, + onSelected: (selected) { + final updated = List.from(selectedWeekdays); + if (selected) { + updated.add(weekday); + } else { + updated.remove(weekday); + } + onChanged(updated); + }, + ); + }).toList(), + ); + } +} diff --git a/lib/features/statistics/data/dive_filter_sql.dart b/lib/features/statistics/data/dive_filter_sql.dart index bf4e48ec8d..2e44dc60ad 100644 --- a/lib/features/statistics/data/dive_filter_sql.dart +++ b/lib/features/statistics/data/dive_filter_sql.dart @@ -58,6 +58,19 @@ import 'package:submersion/features/equipment/domain/constants/equipment_attribu params.addAll(filter.tagIds); } + // Weekdays: match ANY selected weekday. dive_date_time is wall-clock-as-UTC + // epoch ms, so strftime('%w', ...) (0=Sunday..6=Saturday) already lines up + // with the wall-clock day -- no 'utc' modifier needed. Converting + // DateTime.weekday (1=Monday..7=Sunday) via `% 7` matches that numbering. + if (filter.weekdays.isNotEmpty) { + final ph = List.filled(filter.weekdays.length, '?').join(', '); + conditions.add( + "CAST(strftime('%w', dive_date_time / 1000, 'unixepoch') AS INTEGER) " + 'IN ($ph)', + ); + params.addAll(filter.weekdays.map((w) => w % 7)); + } + // Equipment: match ANY selected item. if (filter.equipmentIds.isNotEmpty) { final ph = List.filled(filter.equipmentIds.length, '?').join(', '); diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index dcc1528cfe..ee92bc6ec9 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2047,6 +2047,7 @@ "diveLog_filter_clearAll": "مسح الكل", "diveLog_filter_clearDates": "مسح التواريخ", "diveLog_filter_clearRating": "مسح تصفية التقييم", + "diveLog_filter_clearWeekdays": "مسح أيام الأسبوع", "diveLog_filter_dateSeparator": "إلى", "diveLog_filter_endDate": "تاريخ الانتهاء", "diveLog_filter_errorLoadingSites": "خطأ في تحميل المواقع", @@ -2072,6 +2073,7 @@ "diveLog_filter_sectionGasMix": "خليط الغاز (O₂%)", "diveLog_filter_sectionMinRating": "الحد الأدنى للتقييم", "diveLog_filter_sectionTags": "الوسوم", + "diveLog_filter_sectionWeekdays": "أيام الأسبوع", "diveLog_filter_showOnlyFavorites": "عرض الغوصات المفضلة فقط", "diveLog_filter_showOnlyNoBuddy": "عرض الغوصات بدون زميل غوص فقط", "diveLog_filter_startDate": "تاريخ البدء", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index cb74f9fded..e7b76f4996 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -2047,6 +2047,7 @@ "diveLog_filter_clearAll": "Alle zurücksetzen", "diveLog_filter_clearDates": "Daten zurücksetzen", "diveLog_filter_clearRating": "Bewertungsfilter zurücksetzen", + "diveLog_filter_clearWeekdays": "Wochentage zurücksetzen", "diveLog_filter_dateSeparator": "bis", "diveLog_filter_endDate": "Enddatum", "diveLog_filter_errorLoadingSites": "Fehler beim Laden der Tauchplätze", @@ -2072,6 +2073,7 @@ "diveLog_filter_sectionGasMix": "Gasgemisch (O₂%)", "diveLog_filter_sectionMinRating": "Mindestbewertung", "diveLog_filter_sectionTags": "Tags", + "diveLog_filter_sectionWeekdays": "Wochentage", "diveLog_filter_showOnlyFavorites": "Nur Favoriten anzeigen", "diveLog_filter_showOnlyNoBuddy": "Nur Tauchgänge ohne Buddy anzeigen", "diveLog_filter_startDate": "Startdatum", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 18ddd561c6..1c8393d31c 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3201,6 +3201,7 @@ "diveLog_filter_clearAll": "Clear All", "diveLog_filter_clearDates": "Clear dates", "diveLog_filter_clearRating": "Clear rating filter", + "diveLog_filter_clearWeekdays": "Clear weekdays", "diveLog_filter_dateSeparator": "to", "diveLog_filter_endDate": "End Date", "diveLog_filter_errorLoadingSites": "Error loading sites", @@ -3226,6 +3227,7 @@ "diveLog_filter_sectionGasMix": "Gas Mix (O₂%)", "diveLog_filter_sectionMinRating": "Minimum Rating", "diveLog_filter_sectionTags": "Tags", + "diveLog_filter_sectionWeekdays": "Weekdays", "diveLog_filter_showOnlyFavorites": "Show only favorite dives", "diveLog_filter_showOnlyNoBuddy": "Show only dives without a buddy", "diveLog_filter_startDate": "Start Date", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 3d8d0e4c5f..98a7560850 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2047,6 +2047,7 @@ "diveLog_filter_clearAll": "Borrar todo", "diveLog_filter_clearDates": "Borrar fechas", "diveLog_filter_clearRating": "Borrar filtro de valoración", + "diveLog_filter_clearWeekdays": "Borrar días de la semana", "diveLog_filter_dateSeparator": "hasta", "diveLog_filter_endDate": "Fecha de fin", "diveLog_filter_errorLoadingSites": "Error al cargar puntos de buceo", @@ -2072,6 +2073,7 @@ "diveLog_filter_sectionGasMix": "Mezcla de gas (O₂%)", "diveLog_filter_sectionMinRating": "Valoración mínima", "diveLog_filter_sectionTags": "Etiquetas", + "diveLog_filter_sectionWeekdays": "Días de la semana", "diveLog_filter_showOnlyFavorites": "Mostrar solo inmersiones favoritas", "diveLog_filter_showOnlyNoBuddy": "Mostrar solo inmersiones sin compañero", "diveLog_filter_startDate": "Fecha de inicio", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 7eee84835b..aec1971e0e 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1974,6 +1974,7 @@ "diveLog_filter_clearAll": "Tout effacer", "diveLog_filter_clearDates": "Effacer les dates", "diveLog_filter_clearRating": "Effacer le filtre d'evaluation", + "diveLog_filter_clearWeekdays": "Effacer les jours de la semaine", "diveLog_filter_dateSeparator": "au", "diveLog_filter_endDate": "Date de fin", "diveLog_filter_errorLoadingSites": "Erreur lors du chargement des sites", @@ -1999,6 +2000,7 @@ "diveLog_filter_sectionGasMix": "Melange gazeux (O₂%)", "diveLog_filter_sectionMinRating": "Evaluation minimum", "diveLog_filter_sectionTags": "Tags", + "diveLog_filter_sectionWeekdays": "Jours de la semaine", "diveLog_filter_showOnlyFavorites": "Afficher uniquement les plongees favorites", "diveLog_filter_showOnlyNoBuddy": "Afficher uniquement les plongees sans binome", "diveLog_filter_startDate": "Date de debut", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 7ec4ac806e..51bdfdedac 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1974,6 +1974,7 @@ "diveLog_filter_clearAll": "ניקוי הכל", "diveLog_filter_clearDates": "ניקוי תאריכים", "diveLog_filter_clearRating": "ניקוי מסנן דירוג", + "diveLog_filter_clearWeekdays": "ניקוי ימי השבוע", "diveLog_filter_dateSeparator": "עד", "diveLog_filter_endDate": "תאריך סיום", "diveLog_filter_errorLoadingSites": "שגיאה בטעינת אתרים", @@ -1999,6 +2000,7 @@ "diveLog_filter_sectionGasMix": "תערובת גזים (O₂%)", "diveLog_filter_sectionMinRating": "דירוג מינימלי", "diveLog_filter_sectionTags": "תגיות", + "diveLog_filter_sectionWeekdays": "ימי השבוע", "diveLog_filter_showOnlyFavorites": "הצגת צלילות מועדפות בלבד", "diveLog_filter_showOnlyNoBuddy": "הצגת צלילות ללא שותף בלבד", "diveLog_filter_startDate": "תאריך התחלה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 93aa140b10..f6141a21a7 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1974,6 +1974,7 @@ "diveLog_filter_clearAll": "Osszes torlese", "diveLog_filter_clearDates": "Datumok torlese", "diveLog_filter_clearRating": "Ertekeles szuro torlese", + "diveLog_filter_clearWeekdays": "Het napjai torlese", "diveLog_filter_dateSeparator": "tol", "diveLog_filter_endDate": "Zaras datuma", "diveLog_filter_errorLoadingSites": "Hiba a merulohelyek betoltesekor", @@ -1999,6 +2000,7 @@ "diveLog_filter_sectionGasMix": "Gazkeverek (O₂%)", "diveLog_filter_sectionMinRating": "Minimum ertekeles", "diveLog_filter_sectionTags": "Cimkek", + "diveLog_filter_sectionWeekdays": "Het napjai", "diveLog_filter_showOnlyFavorites": "Csak kedvenc merulesek mutatasa", "diveLog_filter_showOnlyNoBuddy": "Csak buddy nelkuli merulesek mutatasa", "diveLog_filter_startDate": "Kezdes datuma", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 72ccf84c4a..20b17751d1 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1974,6 +1974,7 @@ "diveLog_filter_clearAll": "Cancella tutto", "diveLog_filter_clearDates": "Cancella date", "diveLog_filter_clearRating": "Cancella filtro valutazione", + "diveLog_filter_clearWeekdays": "Cancella giorni della settimana", "diveLog_filter_dateSeparator": "a", "diveLog_filter_endDate": "Data di fine", "diveLog_filter_errorLoadingSites": "Errore nel caricamento dei siti", @@ -1999,6 +2000,7 @@ "diveLog_filter_sectionGasMix": "Miscela gas (O2%)", "diveLog_filter_sectionMinRating": "Valutazione minima", "diveLog_filter_sectionTags": "Tag", + "diveLog_filter_sectionWeekdays": "Giorni della settimana", "diveLog_filter_showOnlyFavorites": "Mostra solo le immersioni preferite", "diveLog_filter_showOnlyNoBuddy": "Mostra solo le immersioni senza compagno", "diveLog_filter_startDate": "Data di inizio", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 03a26270a7..2d773b9ca5 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -9599,6 +9599,12 @@ abstract class AppLocalizations { /// **'Clear rating filter'** String get diveLog_filter_clearRating; + /// No description provided for @diveLog_filter_clearWeekdays. + /// + /// In en, this message translates to: + /// **'Clear weekdays'** + String get diveLog_filter_clearWeekdays; + /// No description provided for @diveLog_filter_dateSeparator. /// /// In en, this message translates to: @@ -9749,6 +9755,12 @@ abstract class AppLocalizations { /// **'Tags'** String get diveLog_filter_sectionTags; + /// No description provided for @diveLog_filter_sectionWeekdays. + /// + /// In en, this message translates to: + /// **'Weekdays'** + String get diveLog_filter_sectionWeekdays; + /// No description provided for @diveLog_filter_showOnlyFavorites. /// /// 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 ba59ba5678..29f24f67b5 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -5663,6 +5663,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveLog_filter_clearRating => 'مسح تصفية التقييم'; + @override + String get diveLog_filter_clearWeekdays => 'مسح أيام الأسبوع'; + @override String get diveLog_filter_dateSeparator => 'إلى'; @@ -5738,6 +5741,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'الوسوم'; + @override + String get diveLog_filter_sectionWeekdays => 'أيام الأسبوع'; + @override String get diveLog_filter_showOnlyFavorites => 'عرض الغوصات المفضلة فقط'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index fbc3bed200..b1a5161a3c 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -5781,6 +5781,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Bewertungsfilter zurücksetzen'; + @override + String get diveLog_filter_clearWeekdays => 'Wochentage zurücksetzen'; + @override String get diveLog_filter_dateSeparator => 'bis'; @@ -5857,6 +5860,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Tags'; + @override + String get diveLog_filter_sectionWeekdays => 'Wochentage'; + @override String get diveLog_filter_showOnlyFavorites => 'Nur Favoriten anzeigen'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index aefb183ea0..80a19d1476 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -5677,6 +5677,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Clear rating filter'; + @override + String get diveLog_filter_clearWeekdays => 'Clear weekdays'; + @override String get diveLog_filter_dateSeparator => 'to'; @@ -5752,6 +5755,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Tags'; + @override + String get diveLog_filter_sectionWeekdays => 'Weekdays'; + @override String get diveLog_filter_showOnlyFavorites => 'Show only favorite dives'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index ded525b7af..1a2131c8be 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -5786,6 +5786,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Borrar filtro de valoración'; + @override + String get diveLog_filter_clearWeekdays => 'Borrar días de la semana'; + @override String get diveLog_filter_dateSeparator => 'hasta'; @@ -5863,6 +5866,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Etiquetas'; + @override + String get diveLog_filter_sectionWeekdays => 'Días de la semana'; + @override String get diveLog_filter_showOnlyFavorites => 'Mostrar solo inmersiones favoritas'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 2f87decbda..e006ab2eb5 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -5806,6 +5806,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Effacer le filtre d\'evaluation'; + @override + String get diveLog_filter_clearWeekdays => 'Effacer les jours de la semaine'; + @override String get diveLog_filter_dateSeparator => 'au'; @@ -5883,6 +5886,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Tags'; + @override + String get diveLog_filter_sectionWeekdays => 'Jours de la semaine'; + @override String get diveLog_filter_showOnlyFavorites => 'Afficher uniquement les plongees favorites'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index b165176b87..bd1c3b3804 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -5634,6 +5634,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveLog_filter_clearRating => 'ניקוי מסנן דירוג'; + @override + String get diveLog_filter_clearWeekdays => 'ניקוי ימי השבוע'; + @override String get diveLog_filter_dateSeparator => 'עד'; @@ -5709,6 +5712,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'תגיות'; + @override + String get diveLog_filter_sectionWeekdays => 'ימי השבוע'; + @override String get diveLog_filter_showOnlyFavorites => 'הצגת צלילות מועדפות בלבד'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index db77dc8569..c437fe7da1 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -5765,6 +5765,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Ertekeles szuro torlese'; + @override + String get diveLog_filter_clearWeekdays => 'Het napjai torlese'; + @override String get diveLog_filter_dateSeparator => 'tol'; @@ -5841,6 +5844,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Cimkek'; + @override + String get diveLog_filter_sectionWeekdays => 'Het napjai'; + @override String get diveLog_filter_showOnlyFavorites => 'Csak kedvenc merulesek mutatasa'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index bd3b858ad7..6448a4f6bc 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -5783,6 +5783,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Cancella filtro valutazione'; + @override + String get diveLog_filter_clearWeekdays => 'Cancella giorni della settimana'; + @override String get diveLog_filter_dateSeparator => 'a'; @@ -5861,6 +5864,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Tag'; + @override + String get diveLog_filter_sectionWeekdays => 'Giorni della settimana'; + @override String get diveLog_filter_showOnlyFavorites => 'Mostra solo le immersioni preferite'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 3a0da4174a..c8d0656c76 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -5741,6 +5741,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Beoordelingsfilter wissen'; + @override + String get diveLog_filter_clearWeekdays => 'Weekdagen wissen'; + @override String get diveLog_filter_dateSeparator => 'tot'; @@ -5816,6 +5819,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Tags'; + @override + String get diveLog_filter_sectionWeekdays => 'Weekdagen'; + @override String get diveLog_filter_showOnlyFavorites => 'Toon alleen favoriete duiken'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 16b24e49c9..47c6299be5 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -5788,6 +5788,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveLog_filter_clearRating => 'Limpar filtro de avaliacao'; + @override + String get diveLog_filter_clearWeekdays => 'Limpar dias da semana'; + @override String get diveLog_filter_dateSeparator => 'ate'; @@ -5865,6 +5868,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'Tags'; + @override + String get diveLog_filter_sectionWeekdays => 'Dias da semana'; + @override String get diveLog_filter_showOnlyFavorites => 'Mostrar apenas mergulhos favoritos'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 15c3f12bf7..ef772ddcb3 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -5492,6 +5492,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveLog_filter_clearRating => '清除评分筛选'; + @override + String get diveLog_filter_clearWeekdays => '清除星期筛选'; + @override String get diveLog_filter_dateSeparator => '至'; @@ -5567,6 +5570,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveLog_filter_sectionTags => '标签'; + @override + String get diveLog_filter_sectionWeekdays => '星期'; + @override String get diveLog_filter_showOnlyFavorites => '仅显示收藏的潜水'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 4ed723423c..a900d264ad 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -2047,6 +2047,7 @@ "diveLog_filter_clearAll": "Alles wissen", "diveLog_filter_clearDates": "Datums wissen", "diveLog_filter_clearRating": "Beoordelingsfilter wissen", + "diveLog_filter_clearWeekdays": "Weekdagen wissen", "diveLog_filter_dateSeparator": "tot", "diveLog_filter_endDate": "Einddatum", "diveLog_filter_errorLoadingSites": "Fout bij laden van stekken", @@ -2072,6 +2073,7 @@ "diveLog_filter_sectionGasMix": "Gasmix (O₂%)", "diveLog_filter_sectionMinRating": "Minimale beoordeling", "diveLog_filter_sectionTags": "Tags", + "diveLog_filter_sectionWeekdays": "Weekdagen", "diveLog_filter_showOnlyFavorites": "Toon alleen favoriete duiken", "diveLog_filter_showOnlyNoBuddy": "Toon alleen duiken zonder buddy", "diveLog_filter_startDate": "Startdatum", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 9a4d020be7..471777ac8e 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2047,6 +2047,7 @@ "diveLog_filter_clearAll": "Limpar Tudo", "diveLog_filter_clearDates": "Limpar datas", "diveLog_filter_clearRating": "Limpar filtro de avaliacao", + "diveLog_filter_clearWeekdays": "Limpar dias da semana", "diveLog_filter_dateSeparator": "ate", "diveLog_filter_endDate": "Data Final", "diveLog_filter_errorLoadingSites": "Erro ao carregar pontos de mergulho", @@ -2072,6 +2073,7 @@ "diveLog_filter_sectionGasMix": "Mistura de Gas (O₂%)", "diveLog_filter_sectionMinRating": "Avaliacao Minima", "diveLog_filter_sectionTags": "Tags", + "diveLog_filter_sectionWeekdays": "Dias da semana", "diveLog_filter_showOnlyFavorites": "Mostrar apenas mergulhos favoritos", "diveLog_filter_showOnlyNoBuddy": "Mostrar apenas mergulhos sem dupla", "diveLog_filter_startDate": "Data Inicial", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index f51004e910..dc97fe77e5 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2178,6 +2178,7 @@ "diveLog_filter_clearAll": "清除全部", "diveLog_filter_clearDates": "清除日期", "diveLog_filter_clearRating": "清除评分筛选", + "diveLog_filter_clearWeekdays": "清除星期筛选", "diveLog_filter_dateSeparator": "至", "diveLog_filter_endDate": "结束日期", "diveLog_filter_errorLoadingSites": "加载潜水点出错", @@ -2203,6 +2204,7 @@ "diveLog_filter_sectionGasMix": "气体混合 (O₂%)", "diveLog_filter_sectionMinRating": "最低评分", "diveLog_filter_sectionTags": "标签", + "diveLog_filter_sectionWeekdays": "星期", "diveLog_filter_showOnlyFavorites": "仅显示收藏的潜水", "diveLog_filter_showOnlyNoBuddy": "仅显示无潜伴的潜水", "diveLog_filter_startDate": "开始日期", diff --git a/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart b/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart new file mode 100644 index 0000000000..f8542d99e3 --- /dev/null +++ b/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as domain; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late DiveRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = DiveRepository(); + }); + tearDown(() async => tearDownTestDatabase()); + + test('SQL filter matches ANY selected weekday', () async { + // 28 days apart (4 whole weeks) guarantees the same weekday regardless + // of which actual day of the week these calendar dates land on. + final mondayA = DateTime(2026, 6, 8); + final mondayB = DateTime(2026, 7, 6); + final tuesday = DateTime(2026, 6, 9); + await repository.createDive(domain.Dive(id: 'd1', dateTime: mondayA)); + await repository.createDive(domain.Dive(id: 'd2', dateTime: mondayB)); + await repository.createDive(domain.Dive(id: 'd3', dateTime: tuesday)); + + final results = await repository.getDiveSummaries( + filter: DiveFilterState(weekdays: [mondayA.weekday]), + ); + final ids = results.map((d) => d.id).toSet(); + expect(ids, {'d1', 'd2'}); + }); + + test('SQL filter ANDs weekday with date range', () async { + final mondayInRange = DateTime(2026, 6, 8); + final mondayOutOfRange = DateTime(2026, 7, 6); + final tuesdayInRange = DateTime(2026, 6, 9); + await repository.createDive( + domain.Dive(id: 'd1', dateTime: mondayInRange), + ); + await repository.createDive( + domain.Dive(id: 'd2', dateTime: mondayOutOfRange), + ); + await repository.createDive( + domain.Dive(id: 'd3', dateTime: tuesdayInRange), + ); + + final results = await repository.getDiveSummaries( + filter: DiveFilterState( + startDate: DateTime(2026, 6, 1), + endDate: DateTime(2026, 6, 30), + weekdays: [mondayInRange.weekday], + ), + ); + expect(results.map((d) => d.id).toSet(), {'d1'}); + }); + + test('in-memory apply() matches by weekday membership', () { + final monday = DateTime(2026, 6, 8); + final tuesday = DateTime(2026, 6, 9); + final dives = [ + domain.Dive(id: 'a', dateTime: monday), + domain.Dive(id: 'b', dateTime: tuesday), + ]; + final filtered = DiveFilterState( + weekdays: [monday.weekday], + ).apply(dives); + expect(filtered.map((d) => d.id), ['a']); + }); +} diff --git a/test/features/dive_log/domain/models/dive_filter_state_test.dart b/test/features/dive_log/domain/models/dive_filter_state_test.dart index 6d7b5b6456..dbf31ceca7 100644 --- a/test/features/dive_log/domain/models/dive_filter_state_test.dart +++ b/test/features/dive_log/domain/models/dive_filter_state_test.dart @@ -77,6 +77,7 @@ void main() { expect(filter.favoritesOnly, isNull); expect(filter.noBuddyOnly, isNull); expect(filter.tagIds, isEmpty); + expect(filter.weekdays, isEmpty); expect(filter.equipmentIds, isEmpty); expect(filter.buddyNameFilter, isNull); expect(filter.buddyId, isNull); @@ -171,6 +172,12 @@ void main() { expect(filter.hasActiveFilters, isTrue); }); + test('returns true when weekdays is non-empty', () { + const filter = DiveFilterState(weekdays: [1, 3]); + + expect(filter.hasActiveFilters, isTrue); + }); + test('returns true when buddyNameFilter is set and non-empty', () { const filter = DiveFilterState(buddyNameFilter: 'John'); @@ -237,6 +244,22 @@ void main() { expect(updated.noBuddyOnly, isNull); }); + test('sets weekdays', () { + const original = DiveFilterState(); + + final updated = original.copyWith(weekdays: [1, 2]); + + expect(updated.weekdays, [1, 2]); + }); + + test('clears weekdays with clearWeekdays', () { + const original = DiveFilterState(weekdays: [1, 2]); + + final updated = original.copyWith(clearWeekdays: true); + + expect(updated.weekdays, isEmpty); + }); + test('sets and clears multiple fields simultaneously', () { const original = DiveFilterState( minRating: 3, @@ -451,6 +474,61 @@ void main() { expect(result.first.id, 'd1'); }); + group('weekdays', () { + test('filters by matching weekday', () { + final monday = DateTime(2026, 3, 16); + final tuesday = DateTime(2026, 3, 17); + final filter = DiveFilterState(weekdays: [monday.weekday]); + final dives = [ + _makeDive(id: 'd1', dateTime: monday), + _makeDive(id: 'd2', dateTime: tuesday), + ]; + + final result = filter.apply(dives); + + expect(result.map((d) => d.id), ['d1']); + }); + + test('matches ANY selected weekday', () { + final monday = DateTime(2026, 3, 16); + final tuesday = DateTime(2026, 3, 17); + final wednesday = DateTime(2026, 3, 18); + final filter = DiveFilterState( + weekdays: [monday.weekday, wednesday.weekday], + ); + final dives = [ + _makeDive(id: 'd1', dateTime: monday), + _makeDive(id: 'd2', dateTime: tuesday), + _makeDive(id: 'd3', dateTime: wednesday), + ]; + + final result = filter.apply(dives); + + expect(result.map((d) => d.id), containsAll(['d1', 'd3'])); + expect(result, hasLength(2)); + }); + + test('combines with date range as AND', () { + final insideRangeMonday = DateTime(2026, 3, 16); + final outsideRangeMonday = DateTime(2026, 4, 6); + final insideRangeTuesday = DateTime(2026, 3, 17); + final filter = DiveFilterState( + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + weekdays: [insideRangeMonday.weekday], + ); + final dives = [ + _makeDive(id: 'd1', dateTime: insideRangeMonday), + _makeDive(id: 'd2', dateTime: outsideRangeMonday), + _makeDive(id: 'd3', dateTime: insideRangeTuesday), + ]; + + final result = filter.apply(dives); + + expect(result.map((d) => d.id), ['d1']); + }); + }); + test('filters by diveIds', () { const filter = DiveFilterState(diveIds: ['d1', 'd3']); final dives = [ diff --git a/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart b/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart new file mode 100644 index 0000000000..8c8e28ed9d --- /dev/null +++ b/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/weekday_filter_selector.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +Future _pump( + WidgetTester tester, { + required List selected, + required ValueChanged> onChanged, + Locale locale = const Locale('en'), +}) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) { + capturedContext = context; + return WeekdayFilterSelector( + selectedWeekdays: selected, + onChanged: onChanged, + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return capturedContext; +} + +void main() { + testWidgets('renders all seven weekdays as chips', (tester) async { + await _pump(tester, selected: const [], onChanged: (_) {}); + + expect(find.byType(FilterChip), findsNWidgets(7)); + }); + + testWidgets('orders chips starting from the locale week start', ( + tester, + ) async { + final context = await _pump(tester, selected: const [], onChanged: (_) {}); + + final materialLocalizations = MaterialLocalizations.of(context); + final firstWeekday = materialLocalizations.firstDayOfWeekIndex == 0 + ? 7 + : materialLocalizations.firstDayOfWeekIndex; + final expectedFirstLabel = weekdayAbbreviation(context, firstWeekday); + + final firstChip = tester.widget( + find.byType(FilterChip).first, + ); + final labelText = (firstChip.label as Text).data; + + expect(labelText, expectedFirstLabel); + }); + + testWidgets('marks selected weekdays as selected chips', (tester) async { + await _pump(tester, selected: const [1], onChanged: (_) {}); + + final chips = tester.widgetList(find.byType(FilterChip)); + final selectedCount = chips.where((c) => c.selected).length; + + expect(selectedCount, 1); + }); + + testWidgets('tapping an unselected chip adds its weekday', (tester) async { + List? result; + await _pump( + tester, + selected: const [], + onChanged: (weekdays) => result = weekdays, + ); + + await tester.tap(find.byType(FilterChip).first); + await tester.pumpAndSettle(); + + expect(result, hasLength(1)); + }); + + testWidgets('tapping a selected chip removes its weekday', (tester) async { + List? result; + await _pump( + tester, + selected: const [1, 2, 3, 4, 5, 6, 7], + onChanged: (weekdays) => result = weekdays, + ); + + await tester.tap(find.byType(FilterChip).first); + await tester.pumpAndSettle(); + + expect(result, hasLength(6)); + }); +} diff --git a/test/features/statistics/data/dive_filter_sql_test.dart b/test/features/statistics/data/dive_filter_sql_test.dart index d42c84cee0..5c788c3a05 100644 --- a/test/features/statistics/data/dive_filter_sql_test.dart +++ b/test/features/statistics/data/dive_filter_sql_test.dart @@ -288,6 +288,44 @@ void main() { }); }); + test('weekday filter matches ANY selected weekday', () async { + // 28 days apart (4 whole weeks) guarantees the same weekday regardless + // of which actual day of the week these calendar dates land on. + final mondayA = DateTime(2026, 6, 8); + final mondayB = DateTime(2026, 7, 6); + final tuesday = DateTime(2026, 6, 9); + await insertDive('mon-a', date: mondayA); + await insertDive('mon-b', date: mondayB); + await insertDive('tue', date: tuesday); + + expect(await idsMatching(DiveFilterState(weekdays: [mondayA.weekday])), { + 'mon-a', + 'mon-b', + }); + expect( + await idsMatching( + DiveFilterState(weekdays: [mondayA.weekday, tuesday.weekday]), + ), + {'mon-a', 'mon-b', 'tue'}, + ); + }); + + test('weekday filter ANDs with date range when both are set', () async { + final mondayInRange = DateTime(2026, 6, 8); + final mondayOutOfRange = DateTime(2026, 7, 6); + final tuesdayInRange = DateTime(2026, 6, 9); + await insertDive('mon-in', date: mondayInRange); + await insertDive('mon-out', date: mondayOutOfRange); + await insertDive('tue-in', date: tuesdayInRange); + + final filter = DiveFilterState( + startDate: DateTime(2026, 6, 1), + endDate: DateTime(2026, 6, 30), + weekdays: [mondayInRange.weekday], + ); + expect(await idsMatching(filter), {'mon-in'}); + }); + test('site, depth, rating, favorites axes', () async { await insertSite('s1'); await insertDive( @@ -553,6 +591,9 @@ void main() { 'diveCenterId': const DiveFilterState(diveCenterId: 'c1'), 'tripId': const DiveFilterState(tripId: 't1'), 'single tag': const DiveFilterState(tagIds: ['dry']), + 'weekday (ANY)': DiveFilterState( + weekdays: [DateTime(2026, 1, 10).weekday, DateTime(2026, 4, 1).weekday], + ), 'multi tag (ANY)': const DiveFilterState(tagIds: ['dry', 'night']), 'equipment (ANY)': const DiveFilterState(equipmentIds: ['eq1']), 'minDepth (null-exclusion)': const DiveFilterState(minDepth: 20), From 6ae7b827659f6f3f5a6b0c1b84538beed3ed9ee9 Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Mon, 24 Aug 2026 20:50:15 +0200 Subject: [PATCH 005/122] feat(settings): add persisted default for CCR O2 cell visibility The per-cell O2 mV toggle added for issue #810 had no persisted default, unlike every other dive profile chart metric, so it always started off each session. Adds defaultShowO2CellMv (schema v161) following the same pattern as the other default-visible metrics, and surfaces it in the Gas Analysis Metrics group of Settings > Appearance > Dives. Fixes #1235 --- lib/core/database/database.dart | 35 ++++++++++- .../services/sync/sync_data_serializer.dart | 3 + .../providers/profile_legend_provider.dart | 6 +- .../diver_settings_repository.dart | 3 + .../pages/default_visible_metrics_page.dart | 5 ++ .../pages/section_appearance_page.dart | 3 +- .../providers/settings_providers.dart | 11 ++++ ...igration_v161_o2_cell_mv_default_test.dart | 62 +++++++++++++++++++ .../sync_diver_settings_fallback_test.dart | 37 +++++++++++ .../profile_legend_provider_test.dart | 31 +++++++++- ...r_settings_repository_o2_cell_mv_test.dart | 52 ++++++++++++++++ .../default_visible_metrics_page_test.dart | 22 +++++++ .../pages/section_appearance_page_test.dart | 4 +- .../pages/settings_page_shared_data_test.dart | 3 + .../pages/settings_page_test.dart | 3 + .../presentation/pages/records_page_test.dart | 3 + test/helpers/mock_providers.dart | 3 + 17 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 test/core/database/migration_v161_o2_cell_mv_default_test.dart create mode 100644 test/features/settings/data/repositories/diver_settings_repository_o2_cell_mv_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 964f389169..92124f1a74 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1807,6 +1807,9 @@ class DiverSettings extends Table { boolean().withDefault(const Constant(true))(); BoolColumn get defaultShowGasTimeline => boolean().withDefault(const Constant(false))(); + // v161: default visibility for the per-cell O2 mV traces (issue #1235). + BoolColumn get defaultShowO2CellMv => + boolean().withDefault(const Constant(false))(); // Drift column declarations are codegen inputs shadowed by the generated // table at runtime, so this line is never executed (every sibling column // getter is likewise uncovered). The default is verified via the migration @@ -3162,7 +3165,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 = 160; + static const int currentSchemaVersion = 161; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3444,6 +3447,9 @@ class AppDatabase extends _$AppDatabase { // service_records.service_type -> service_category rename. Renumbered // from 158 and then 159, which #1149 and #1177 claimed first on main. 160, + // 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, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4896,6 +4902,24 @@ class AppDatabase extends _$AppDatabase { } } + /// v161: default_show_o2_cell_mv on diver_settings (issue #1235). The + /// per-cell O2 mV toggle previously had no persisted default; this lets a + /// diver make it visible by default on the profile chart. + Future _assertO2CellMvDefaultColumn() 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('default_show_o2_cell_mv')) { + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN default_show_o2_cell_mv ' + 'INTEGER NOT NULL DEFAULT 0 ' + 'CHECK (default_show_o2_cell_mv 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 @@ -8506,6 +8530,11 @@ class AppDatabase extends _$AppDatabase { await _assertServiceCategoryRename(); } if (from < 160) await reportProgress(); + // v161: default_show_o2_cell_mv on diver_settings (issue #1235). + if (from < 161) { + await _assertO2CellMvDefaultColumn(); + } + if (from < 161) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -8696,6 +8725,10 @@ class AppDatabase extends _$AppDatabase { // onUpgrade, and every read of a service record would throw. await _assertServiceCategoryRename(); + // v161 backstop: re-assert diver_settings.default_show_o2_cell_mv + // (issue #1235; same parallel-branch version-collision self-heal). + await _assertO2CellMvDefaultColumn(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index b96af23fb2..a942e36284 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -5660,6 +5660,9 @@ class SyncDataSerializer { 'defaultShowOtu': false, 'defaultShowGasSwitchMarkers': true, 'defaultShowGasTimeline': false, + // v161: seed it so payloads predating the column hydrate instead of + // throwing in DiverSetting.fromJson. + 'defaultShowO2CellMv': false, // Dive profile default-visible metrics. Non-nullable bool added in v91; // seed it so payloads predating the column hydrate instead of throwing in // DiverSetting.fromJson. diff --git a/lib/features/dive_log/presentation/providers/profile_legend_provider.dart b/lib/features/dive_log/presentation/providers/profile_legend_provider.dart index 33eeeb867f..5dd472ad91 100644 --- a/lib/features/dive_log/presentation/providers/profile_legend_provider.dart +++ b/lib/features/dive_log/presentation/providers/profile_legend_provider.dart @@ -54,8 +54,8 @@ class ProfileLegendState { final bool showCns; final bool showOtu; - /// Raw O2 cell output lines (issue #810). Session-only: no persisted - /// default backs it, following the showMod precedent. + /// Raw O2 cell output lines (issue #810). Seeds from the persisted + /// [AppSettings.defaultShowO2CellMv] default (issue #1235). final bool showO2CellMv; // Per-metric data source preferences (session overrides). @@ -364,6 +364,7 @@ class ProfileLegend extends _$ProfileLegend { defaultShowGasSwitchMarkers: s.defaultShowGasSwitchMarkers, defaultShowPhotoMarkers: s.defaultShowPhotoMarkers, defaultShowGasTimeline: s.defaultShowGasTimeline, + defaultShowO2CellMv: s.defaultShowO2CellMv, showNdlOnProfile: s.showNdlOnProfile, defaultShowPpO2: s.defaultShowPpO2, defaultShowPpN2: s.defaultShowPpN2, @@ -399,6 +400,7 @@ class ProfileLegend extends _$ProfileLegend { showGasSwitchMarkers: settings.defaultShowGasSwitchMarkers, showPhotoMarkers: settings.defaultShowPhotoMarkers, showGas: settings.defaultShowGasTimeline, + showO2CellMv: settings.defaultShowO2CellMv, showNdl: settings.showNdlOnProfile, showPpO2: settings.defaultShowPpO2, showPpN2: settings.defaultShowPpN2, diff --git a/lib/features/settings/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index ae273463e5..d8ee465a97 100644 --- a/lib/features/settings/data/repositories/diver_settings_repository.dart +++ b/lib/features/settings/data/repositories/diver_settings_repository.dart @@ -177,6 +177,7 @@ class DiverSettingsRepository { defaultShowGasSwitchMarkers: Value(s.defaultShowGasSwitchMarkers), defaultShowPhotoMarkers: Value(s.defaultShowPhotoMarkers), defaultShowGasTimeline: Value(s.defaultShowGasTimeline), + defaultShowO2CellMv: Value(s.defaultShowO2CellMv), defaultShowAscentRateLine: Value(s.defaultShowAscentRateLine), notificationsEnabled: Value(s.notificationsEnabled), serviceReminderDays: Value( @@ -340,6 +341,7 @@ class DiverSettingsRepository { ), defaultShowPhotoMarkers: Value(settings.defaultShowPhotoMarkers), defaultShowGasTimeline: Value(settings.defaultShowGasTimeline), + defaultShowO2CellMv: Value(settings.defaultShowO2CellMv), defaultShowAscentRateLine: Value(settings.defaultShowAscentRateLine), notificationsEnabled: Value(settings.notificationsEnabled), serviceReminderDays: Value( @@ -541,6 +543,7 @@ class DiverSettingsRepository { defaultShowGasSwitchMarkers: row.defaultShowGasSwitchMarkers, defaultShowPhotoMarkers: row.defaultShowPhotoMarkers, defaultShowGasTimeline: row.defaultShowGasTimeline, + defaultShowO2CellMv: row.defaultShowO2CellMv, defaultShowAscentRateLine: row.defaultShowAscentRateLine, notificationsEnabled: row.notificationsEnabled, serviceReminderDays: _parseReminderDays(row.serviceReminderDays), diff --git a/lib/features/settings/presentation/pages/default_visible_metrics_page.dart b/lib/features/settings/presentation/pages/default_visible_metrics_page.dart index 47c4484091..d82e838776 100644 --- a/lib/features/settings/presentation/pages/default_visible_metrics_page.dart +++ b/lib/features/settings/presentation/pages/default_visible_metrics_page.dart @@ -128,6 +128,11 @@ class DefaultVisibleMetricsPage extends ConsumerWidget { value: settings.defaultShowGasDensity, onChanged: notifier.setDefaultShowGasDensity, ), + SwitchListTile( + title: Text(context.l10n.diveLog_legend_label_o2Cells), + value: settings.defaultShowO2CellMv, + onChanged: notifier.setDefaultShowO2CellMv, + ), const Divider(), _buildSectionHeader( context, diff --git a/lib/features/settings/presentation/pages/section_appearance_page.dart b/lib/features/settings/presentation/pages/section_appearance_page.dart index 8de5e03371..721dec3723 100644 --- a/lib/features/settings/presentation/pages/section_appearance_page.dart +++ b/lib/features/settings/presentation/pages/section_appearance_page.dart @@ -576,7 +576,7 @@ class SectionAppearancePage extends ConsumerWidget { subtitle: Text( context.l10n.settings_appearance_metricsEnabledCount( _countEnabledMetrics(settings), - 18, + 19, ), ), trailing: const Icon(Icons.chevron_right), @@ -732,6 +732,7 @@ class SectionAppearancePage extends ConsumerWidget { settings.defaultShowGf, settings.defaultShowSurfaceGf, settings.defaultShowMeanDepth, + settings.defaultShowO2CellMv, ]; return values.where((v) => v).length; } diff --git a/lib/features/settings/presentation/providers/settings_providers.dart b/lib/features/settings/presentation/providers/settings_providers.dart index c1985ee13c..a0b2d2f5d5 100644 --- a/lib/features/settings/presentation/providers/settings_providers.dart +++ b/lib/features/settings/presentation/providers/settings_providers.dart @@ -391,6 +391,9 @@ class AppSettings { /// Default visibility for the gas-usage timeline strip on the dive profile final bool defaultShowGasTimeline; + /// Default visibility for the per-cell O2 mV traces on the dive profile + final bool defaultShowO2CellMv; + /// Default visibility for the separate ascent-rate magnitude line on the /// dive profile (distinct from [showAscentRateColors], which tints the depth /// line by velocity band). @@ -557,6 +560,7 @@ class AppSettings { this.defaultShowGasSwitchMarkers = true, this.defaultShowPhotoMarkers = true, this.defaultShowGasTimeline = false, + this.defaultShowO2CellMv = false, this.defaultShowAscentRateLine = false, // Notification defaults this.notificationsEnabled = true, @@ -716,6 +720,7 @@ class AppSettings { bool? defaultShowGasSwitchMarkers, bool? defaultShowPhotoMarkers, bool? defaultShowGasTimeline, + bool? defaultShowO2CellMv, bool? defaultShowAscentRateLine, bool? notificationsEnabled, List? serviceReminderDays, @@ -864,6 +869,7 @@ class AppSettings { defaultShowPhotoMarkers ?? this.defaultShowPhotoMarkers, defaultShowGasTimeline: defaultShowGasTimeline ?? this.defaultShowGasTimeline, + defaultShowO2CellMv: defaultShowO2CellMv ?? this.defaultShowO2CellMv, defaultShowAscentRateLine: defaultShowAscentRateLine ?? this.defaultShowAscentRateLine, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, @@ -1788,6 +1794,11 @@ class SettingsNotifier extends StateNotifier { await _saveSettings(); } + Future setDefaultShowO2CellMv(bool value) async { + state = state.copyWith(defaultShowO2CellMv: value); + await _saveSettings(); + } + Future setDefaultShowAscentRateLine(bool value) async { state = state.copyWith(defaultShowAscentRateLine: value); await _saveSettings(); diff --git a/test/core/database/migration_v161_o2_cell_mv_default_test.dart b/test/core/database/migration_v161_o2_cell_mv_default_test.dart new file mode 100644 index 0000000000..72a6911f60 --- /dev/null +++ b/test/core/database/migration_v161_o2_cell_mv_default_test.dart @@ -0,0 +1,62 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v161 shape: a diver_settings table without the O2 cell mV +/// default-visibility column, stamped at v160 so the 160->161 upgrade runs. +NativeDatabase _dbAt160() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 160'); + rawDb.execute(''' + CREATE TABLE diver_settings ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + rawDb.execute("INSERT INTO diver_settings (id) VALUES ('settings')"); + }, + ); +} + +void main() { + test('v161 adds default_show_o2_cell_mv defaulting to 0', () async { + final db = AppDatabase(_dbAt160()); + 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('default_show_o2_cell_mv')); + + final row = await db + .customSelect('SELECT default_show_o2_cell_mv FROM diver_settings') + .getSingle(); + expect(row.read('default_show_o2_cell_mv'), 0); + }); + + test('fresh databases get the default_show_o2_cell_mv 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('default_show_o2_cell_mv')); + }); + + test('the helper no-ops when diver_settings is absent', () async { + final native = NativeDatabase.memory( + setup: (rawDb) => rawDb.execute('PRAGMA user_version = 160'), + ); + final db = AppDatabase(native); + addTearDown(db.close); + + await expectLater(db.customSelect('SELECT 1').get(), completes); + }); + + test('v161 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(161)); + expect(AppDatabase.migrationVersions, contains(161)); + }); +} diff --git a/test/core/services/sync/sync_diver_settings_fallback_test.dart b/test/core/services/sync/sync_diver_settings_fallback_test.dart index 6e013cf855..787b4626ce 100644 --- a/test/core/services/sync/sync_diver_settings_fallback_test.dart +++ b/test/core/services/sync/sync_diver_settings_fallback_test.dart @@ -277,4 +277,41 @@ void main() { expect(exported, isNotNull); expect(exported!['gasModel'], 'ideal'); }); + + test( + 'applies a pre-v161 diver_settings payload missing defaultShowO2CellMv', + () async { + await db.customStatement('PRAGMA foreign_keys = OFF'); + + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diverSettings) + .insert( + DiverSettingsCompanion.insert( + id: 'ds8', + diverId: 'diver-8', + createdAt: now, + updatedAt: now, + ), + ); + final exported = await serializer.fetchRecord('diverSettings', 'ds8'); + expect(exported, isNotNull); + + // A peer still on v160 exports no defaultShowO2CellMv. The column is + // NOT NULL, so an unseeded import would throw in DiverSetting.fromJson. + final legacy = Map.from(exported!) + ..remove('defaultShowO2CellMv'); + + await (db.delete( + db.diverSettings, + )..where((t) => t.id.equals('ds8'))).go(); + + await serializer.upsertRecord('diverSettings', legacy); + + final row = await (db.select( + db.diverSettings, + )..where((t) => t.id.equals('ds8'))).getSingle(); + expect(row.defaultShowO2CellMv, isFalse); + }, + ); } diff --git a/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart b/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart index 362e2350e3..25968135ad 100644 --- a/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart +++ b/test/features/dive_log/presentation/providers/profile_legend_provider_test.dart @@ -488,7 +488,7 @@ void main() { expect(on.hashCode, isNot(equals(off.hashCode))); }); - test('toggleO2CellMv flips the state, session-only', () { + test('toggleO2CellMv flips the state', () { final container = ProviderContainer( overrides: [ settingsProvider.overrideWith( @@ -497,10 +497,37 @@ void main() { ], ); addTearDown(container.dispose); - // No persisted default setting backs this one, so it always starts off. expect(container.read(profileLegendProvider).showO2CellMv, isFalse); container.read(profileLegendProvider.notifier).toggleO2CellMv(); expect(container.read(profileLegendProvider).showO2CellMv, isTrue); }); + + test('showO2CellMv seeds from defaultShowO2CellMv when true', () { + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith( + (ref) => _StubSettingsNotifier( + const AppSettings(defaultShowO2CellMv: true), + ), + ), + ], + ); + addTearDown(container.dispose); + expect(container.read(profileLegendProvider).showO2CellMv, isTrue); + }); + + test('showO2CellMv seeds from defaultShowO2CellMv when false', () { + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith( + (ref) => _StubSettingsNotifier( + const AppSettings(defaultShowO2CellMv: false), + ), + ), + ], + ); + addTearDown(container.dispose); + expect(container.read(profileLegendProvider).showO2CellMv, isFalse); + }); }); } diff --git a/test/features/settings/data/repositories/diver_settings_repository_o2_cell_mv_test.dart b/test/features/settings/data/repositories/diver_settings_repository_o2_cell_mv_test.dart new file mode 100644 index 0000000000..ad6ca4224e --- /dev/null +++ b/test/features/settings/data/repositories/diver_settings_repository_o2_cell_mv_test.dart @@ -0,0 +1,52 @@ +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 defaultShowO2CellMv 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 defaultShowO2CellMv to false', () async { + await repository.createSettingsForDiver('d1'); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded, isNotNull); + expect(loaded!.defaultShowO2CellMv, isFalse); + }); + + test('round-trips defaultShowO2CellMv = true through update', () async { + await repository.createSettingsForDiver('d1'); + await repository.updateSettingsForDiver( + 'd1', + const AppSettings(defaultShowO2CellMv: true), + ); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded, isNotNull); + expect(loaded!.defaultShowO2CellMv, isTrue); + }); + }); +} diff --git a/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart b/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart index 7f1e5f01b1..efe7f8b73f 100644 --- a/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart +++ b/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart @@ -25,6 +25,10 @@ class _StubSettingsNotifier extends StateNotifier Future setDefaultShowPhotoMarkers(bool value) async => state = state.copyWith(defaultShowPhotoMarkers: value); + @override + Future setDefaultShowO2CellMv(bool value) async => + state = state.copyWith(defaultShowO2CellMv: value); + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -133,4 +137,22 @@ void main() { await tester.pumpAndSettle(); expect(tester.widget(tile).value, isFalse); }); + + testWidgets('toggles O2 cells default', (tester) async { + await tester.pumpWidget(buildPage(_StubSettingsNotifier())); + await tester.pumpAndSettle(); + + final tile = find.widgetWithText(SwitchListTile, 'O2 cells'); + await tester.dragUntilVisible( + tile, + find.byType(Scrollable), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + expect(tester.widget(tile).value, isFalse); + + await tester.tap(tile); + await tester.pumpAndSettle(); + expect(tester.widget(tile).value, isTrue); + }); } diff --git a/test/features/settings/presentation/pages/section_appearance_page_test.dart b/test/features/settings/presentation/pages/section_appearance_page_test.dart index e107837a3a..29292076c3 100644 --- a/test/features/settings/presentation/pages/section_appearance_page_test.dart +++ b/test/features/settings/presentation/pages/section_appearance_page_test.dart @@ -712,8 +712,8 @@ void main() { await tester.pumpAndSettle(); // Default settings have some metrics enabled. The exact count depends - // on default AppSettings. Find the pattern "X of 18" - expect(find.textContaining('of 18'), findsOneWidget); + // on default AppSettings. Find the pattern "X of 19" + expect(find.textContaining('of 19'), findsOneWidget); }); }); 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 1ad9d04ae0..154aca94f1 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 @@ -528,6 +528,9 @@ class _MockSettingsNotifier extends StateNotifier Future setDefaultShowOtu(bool value) async => state = state.copyWith(defaultShowOtu: value); @override + Future setDefaultShowO2CellMv(bool value) async => + state = state.copyWith(defaultShowO2CellMv: value); + @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); @override diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index d414b8142b..879c192902 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -89,6 +89,9 @@ class _MockSettingsNotifier extends StateNotifier Future setDefaultShowGasTimeline(bool value) async => state = state.copyWith(defaultShowGasTimeline: value); @override + Future setDefaultShowO2CellMv(bool value) async => + state = state.copyWith(defaultShowO2CellMv: value); + @override Future setDefaultShowAscentRateLine(bool value) async => state = state.copyWith(defaultShowAscentRateLine: value); @override diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index 3aa439b3ea..74e46ee91e 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -431,6 +431,9 @@ class _MockSettingsNotifier extends StateNotifier Future setDefaultShowOtu(bool value) async => state = state.copyWith(defaultShowOtu: value); @override + Future setDefaultShowO2CellMv(bool value) async => + state = state.copyWith(defaultShowO2CellMv: value); + @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); @override diff --git a/test/helpers/mock_providers.dart b/test/helpers/mock_providers.dart index 92e8416823..269b46ce5c 100644 --- a/test/helpers/mock_providers.dart +++ b/test/helpers/mock_providers.dart @@ -381,6 +381,9 @@ class MockSettingsNotifier extends StateNotifier Future setDefaultShowPhotoMarkers(bool value) async => state = state.copyWith(defaultShowPhotoMarkers: value); @override + Future setDefaultShowO2CellMv(bool value) async => + state = state.copyWith(defaultShowO2CellMv: value); + @override Future setDefaultShowGasTimeline(bool value) async => state = state.copyWith(defaultShowGasTimeline: value); @override From 9d509bf7c5ba52780cd771cc3c36aca0db96df82 Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Mon, 24 Aug 2026 21:18:58 +0200 Subject: [PATCH 006/122] fix(settings): derive enabled-metrics total from the toggle list Addresses PR review feedback: the summary subtitle hardcoded the total as 19 (18 before this PR), but DefaultVisibleMetricsPage renders 22 SwitchListTiles -- the list backing the count was missing showDecoStopsOnProfile and the total was never derived from it. Both now come from the same list so they cannot drift apart again. --- .../pages/section_appearance_page.dart | 58 ++++++++++--------- .../pages/section_appearance_page_test.dart | 5 +- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/lib/features/settings/presentation/pages/section_appearance_page.dart b/lib/features/settings/presentation/pages/section_appearance_page.dart index 721dec3723..c88143e108 100644 --- a/lib/features/settings/presentation/pages/section_appearance_page.dart +++ b/lib/features/settings/presentation/pages/section_appearance_page.dart @@ -576,7 +576,7 @@ class SectionAppearancePage extends ConsumerWidget { subtitle: Text( context.l10n.settings_appearance_metricsEnabledCount( _countEnabledMetrics(settings), - 19, + _visibleMetricToggles(settings).length, ), ), trailing: const Icon(Icons.chevron_right), @@ -710,30 +710,34 @@ class SectionAppearancePage extends ConsumerWidget { }; } - int _countEnabledMetrics(AppSettings settings) { - final values = [ - settings.defaultShowTemperature, - settings.defaultShowPressure, - settings.defaultShowHeartRate, - settings.defaultShowSac, - settings.defaultShowEvents, - settings.defaultShowPhotoMarkers, - settings.showCeilingOnProfile, - settings.showAscentRateColors, - settings.defaultShowAscentRateLine, - settings.showNdlOnProfile, - settings.defaultShowTts, - settings.defaultShowCns, - settings.defaultShowOtu, - settings.defaultShowPpO2, - settings.defaultShowPpN2, - settings.defaultShowPpHe, - settings.defaultShowGasDensity, - settings.defaultShowGf, - settings.defaultShowSurfaceGf, - settings.defaultShowMeanDepth, - settings.defaultShowO2CellMv, - ]; - return values.where((v) => v).length; - } + /// One entry per SwitchListTile on [DefaultVisibleMetricsPage], in the same + /// order, so the enabled/total counts shown in the summary subtitle can + /// never drift out of sync with what that page actually renders. + List _visibleMetricToggles(AppSettings settings) => [ + settings.defaultShowTemperature, + settings.defaultShowPressure, + settings.defaultShowHeartRate, + settings.defaultShowSac, + settings.defaultShowEvents, + settings.defaultShowPhotoMarkers, + settings.showCeilingOnProfile, + settings.showDecoStopsOnProfile, + settings.showAscentRateColors, + settings.defaultShowAscentRateLine, + settings.showNdlOnProfile, + settings.defaultShowTts, + settings.defaultShowCns, + settings.defaultShowOtu, + settings.defaultShowPpO2, + settings.defaultShowPpN2, + settings.defaultShowPpHe, + settings.defaultShowGasDensity, + settings.defaultShowO2CellMv, + settings.defaultShowGf, + settings.defaultShowSurfaceGf, + settings.defaultShowMeanDepth, + ]; + + int _countEnabledMetrics(AppSettings settings) => + _visibleMetricToggles(settings).where((v) => v).length; } diff --git a/test/features/settings/presentation/pages/section_appearance_page_test.dart b/test/features/settings/presentation/pages/section_appearance_page_test.dart index 29292076c3..98731aa613 100644 --- a/test/features/settings/presentation/pages/section_appearance_page_test.dart +++ b/test/features/settings/presentation/pages/section_appearance_page_test.dart @@ -712,8 +712,9 @@ void main() { await tester.pumpAndSettle(); // Default settings have some metrics enabled. The exact count depends - // on default AppSettings. Find the pattern "X of 19" - expect(find.textContaining('of 19'), findsOneWidget); + // on default AppSettings. Find the pattern "X of 22" -- one entry per + // SwitchListTile on DefaultVisibleMetricsPage. + expect(find.textContaining('of 22'), findsOneWidget); }); }); From 72fd0efecb507c59d22585e9804cd316ea7537eb Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 17:20:25 -0400 Subject: [PATCH 007/122] feat(media): bring Media Library selection onto the shared pattern The Media Library was the last selectable surface still entered by long-press. Its selection state was a bare Set in mediaSelectionProvider, where "selection mode is active" meant "the set is non-empty" -- a model that cannot represent mode-on-with-nothing-checked, so an explicit Select control was not merely missing, it was unrepresentable. MediaLibraryView now owns a SelectionController, wrapped in SelectableListScope and pruned to the visible ids after every frame. mediaSelectionProvider is deleted rather than left as a second owner: two owners is the failure mode where the grid's exit and the controller's re-activation cancel out and strand the bar at "0 selected". MediaSelectionBar keeps all of its media-specific logic, including the dive-linked / site-linked id filtering that stops a bulk unlink latching retainInLibrary on rows that never carried the link, and renders it through the shared SelectionAppBar(shell: pane). The library therefore inherits Select All, Deselect All, Escape and Cmd/Ctrl-A to exit and select, Android back leaving the mode instead of popping the route, and Delete behind the overflow divider. It had none of these. The long-press callbacks are deleted, not unwired, per PR #1021: re-adding the gesture means re-adding plumbing. Tiles gain isSelectionMode so unchecked thumbnails dim, which is what makes an empty-but-active mode visible, and the grouped list's dive headers go inert on the mode rather than on "something is checked" -- the two differ for exactly the first tap after Select. Toolbar note: the control row is all fixed widths, so a fourth button is spent budget. Three default-density icons plus the view-mode selector overflow by 16px at 320dp; the icons are compact for that reason and a fifth control will not fit. Behavior change worth calling out: unchecking the last item no longer drops the bar. A deliberate entry is the user's to end, so the bar stays at "0 selected" until it is closed. That is the app-wide rule the other 23 surfaces already follow. Tested: MediaLibraryView now runs verifySelectionContract, the app-wide contract it had never been held to. Suite 20,053 passed / 19 skipped / zero failures; analyze clean; dart format clean. --- .../pages/media_library_view.dart | 115 ++++++--- .../providers/media_selection_provider.dart | 22 -- .../widgets/media_library_grid.dart | 27 ++- .../widgets/media_library_grouped_list.dart | 13 +- .../widgets/media_library_toolbar.dart | 33 ++- .../widgets/media_selection_bar.dart | 136 +++++------ .../media_library_grouped_list_test.dart | 20 ++ ...media_library_selection_contract_test.dart | 123 ++++++++++ .../media_library_toolbar_test.dart | 61 ++++- .../presentation/media_selection_test.dart | 226 +++++++++++------- .../widgets/media_library_tile_test.dart | 69 +++++- 11 files changed, 594 insertions(+), 251 deletions(-) delete mode 100644 lib/features/media/presentation/providers/media_selection_provider.dart create mode 100644 test/features/media/presentation/media_library_selection_contract_test.dart diff --git a/lib/features/media/presentation/pages/media_library_view.dart b/lib/features/media/presentation/pages/media_library_view.dart index af38dbdc3c..0d2f1b8730 100644 --- a/lib/features/media/presentation/pages/media_library_view.dart +++ b/lib/features/media/presentation/pages/media_library_view.dart @@ -4,7 +4,6 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/media/domain/entities/media_library_filter.dart'; import 'package:submersion/features/media/presentation/pages/media_viewer_page.dart'; import 'package:submersion/features/media/presentation/providers/media_library_providers.dart'; -import 'package:submersion/features/media/presentation/providers/media_selection_provider.dart'; import 'package:submersion/features/media/presentation/widgets/media_library_grid.dart'; import 'package:submersion/features/media/presentation/widgets/media_selection_bar.dart'; import 'package:submersion/features/media/presentation/widgets/media_library_active_filter_chips.dart'; @@ -13,14 +12,37 @@ import 'package:submersion/features/media/presentation/widgets/media_library_gro import 'package:submersion/features/media/presentation/widgets/media_library_groupers.dart'; import 'package:submersion/features/media/presentation/widgets/media_missing_banner.dart'; import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/selection/selectable_list_scope.dart'; +import 'package:submersion/shared/selection/selection_controller.dart'; +import 'package:submersion/shared/selection/selection_state.dart'; /// The Library section content: the filter and sort toolbar, the active /// filter chips, the repair banner while the Missing files facet is active, /// then the active view mode. The by-dive and timeline presentations reuse /// the same paged state. -class MediaLibraryView extends ConsumerWidget { +class MediaLibraryView extends ConsumerStatefulWidget { const MediaLibraryView({super.key}); + @override + ConsumerState createState() => _MediaLibraryViewState(); +} + +class _MediaLibraryViewState extends ConsumerState { + /// Owns the bulk-selection state machine for the library. + /// + /// Deliberately view state rather than a provider: the selection prunes to + /// what is on screen and does not outlive the surface, and a single owner + /// is what keeps "mode is active" and "something is checked" from being + /// the same fact -- which is exactly what the old id-set provider could + /// not express, and why long-press was its only way in. + final SelectionController _selection = SelectionController(); + + @override + void dispose() { + _selection.dispose(); + super.dispose(); + } + void _openViewer( BuildContext context, List entries, @@ -39,40 +61,70 @@ class MediaLibraryView extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final state = ref.watch(mediaLibraryNotifierProvider); final mode = ref.watch(mediaLibraryViewModeProvider); - final selection = ref.watch(mediaSelectionProvider); final showingMissing = ref.watch(mediaLibraryFilterProvider).health == MediaHealthFilter.missing; - return Column( - children: [ - if (selection.isNotEmpty) - MediaSelectionBar( - selectedItems: state.entries - .where((e) => selection.contains(e.item.id)) - .map((e) => e.item) - .toList(), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: MediaLibraryToolbar(), + final visibleIds = state.entries.map((e) => e.item.id).toList(); + // Drop checked ids that a filter or sort change pushed off screen, so a + // bulk action can never reach a row the user cannot see. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _selection.pruneTo(visibleIds); + }); + + return SelectableListScope( + controller: _selection, + selectableIds: visibleIds, + child: ValueListenableBuilder( + valueListenable: _selection, + builder: (context, selection, _) => Column( + children: [ + if (selection.isActive) + MediaSelectionBar( + controller: _selection, + selectableIds: visibleIds, + selectedItems: state.entries + .where((e) => selection.isChecked(e.item.id)) + .map((e) => e.item) + .toList(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: MediaLibraryToolbar( + selection: _selection, + // Filter and sort stay reachable inside the mode -- narrow + // the list, then Select All -- but the control that opens a + // mode already open would just be dead weight. + canSelect: state.entries.isNotEmpty && !selection.isActive, + ), + ), + const MediaLibraryActiveFilterChips(), + if (showingMissing) + MediaMissingBanner(isEmpty: state.entries.isEmpty), + Expanded( + child: _buildBody( + context, + state, + mode, + showingMissing, + selection, + ), + ), + ], ), - const MediaLibraryActiveFilterChips(), - if (showingMissing) MediaMissingBanner(isEmpty: state.entries.isEmpty), - Expanded(child: _buildBody(context, ref, state, mode, showingMissing)), - ], + ), ); } Widget _buildBody( BuildContext context, - WidgetRef ref, MediaLibraryState state, MediaLibraryViewMode mode, bool showingMissing, + SelectionState selection, ) { if (state.isLoading && state.entries.isEmpty) { return const Center(child: CircularProgressIndicator()); @@ -89,18 +141,15 @@ class MediaLibraryView extends ConsumerWidget { void loadMore() => ref.read(mediaLibraryNotifierProvider.notifier).loadMore(); - final selection = ref.watch(mediaSelectionProvider); void handleTap(MediaLibraryEntry entry) { - if (selection.isNotEmpty) { - ref.read(mediaSelectionProvider.notifier).toggle(entry.item.id); + if (selection.isActive) { + _selection.toggle(entry.item.id); } else { _openViewer(context, state.entries, entry); } } - void handleLongPress(MediaLibraryEntry entry) { - ref.read(mediaSelectionProvider.notifier).toggle(entry.item.id); - } + final checkedIds = selection.checkedIds; return switch (mode) { MediaLibraryViewMode.grid => MediaLibraryGrid( @@ -108,24 +157,24 @@ class MediaLibraryView extends ConsumerWidget { hasMore: state.hasMore, onLoadMore: loadMore, onTileTap: (entry, index) => handleTap(entry), - selectedIds: selection, - onTileLongPress: handleLongPress, + selectedIds: checkedIds, + isSelectionMode: selection.isActive, ), MediaLibraryViewMode.byDive => MediaLibraryGroupedList( groups: groupByDive(state.entries), hasMore: state.hasMore, onLoadMore: loadMore, onTileTap: handleTap, - selectedIds: selection, - onTileLongPress: handleLongPress, + selectedIds: checkedIds, + isSelectionMode: selection.isActive, ), MediaLibraryViewMode.timeline => MediaLibraryGroupedList( groups: groupByTimeline(state.entries), hasMore: state.hasMore, onLoadMore: loadMore, onTileTap: handleTap, - selectedIds: selection, - onTileLongPress: handleLongPress, + selectedIds: checkedIds, + isSelectionMode: selection.isActive, ), }; } diff --git a/lib/features/media/presentation/providers/media_selection_provider.dart b/lib/features/media/presentation/providers/media_selection_provider.dart deleted file mode 100644 index 3cd872f601..0000000000 --- a/lib/features/media/presentation/providers/media_selection_provider.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:submersion/core/providers/provider.dart'; - -/// Selected media ids in the library. Selection mode is active iff the set -/// is non-empty; every mutation replaces the set (immutability rule). -final mediaSelectionProvider = - StateNotifierProvider>( - (ref) => MediaSelectionNotifier(), - ); - -class MediaSelectionNotifier extends StateNotifier> { - MediaSelectionNotifier() : super(const {}); - - void toggle(String mediaId) { - state = state.contains(mediaId) - ? {...state}.difference({mediaId}) - : {...state, mediaId}; - } - - void clear() { - state = const {}; - } -} diff --git a/lib/features/media/presentation/widgets/media_library_grid.dart b/lib/features/media/presentation/widgets/media_library_grid.dart index c6e3d98a8e..bcd31d2a50 100644 --- a/lib/features/media/presentation/widgets/media_library_grid.dart +++ b/lib/features/media/presentation/widgets/media_library_grid.dart @@ -14,20 +14,25 @@ class MediaLibraryTile extends StatelessWidget { required this.entry, required this.selected, required this.onTap, - this.onLongPress, + this.isSelectionMode = false, }); final MediaLibraryEntry entry; final bool selected; final VoidCallback onTap; - final VoidCallback? onLongPress; + + /// Whether the surface is in multi-select. Unselected tiles dim, which is + /// what makes an explicitly entered mode legible before anything is + /// checked -- the state the old long-press entry could not even represent. + final bool isSelectionMode; /// Opens the context menu at the pointer. /// /// Desktop-only in practice: `onSecondaryTapDown` does not fire on a /// touchscreen, so mobile reaches the panel through the viewer's info - /// button instead. Long-press is not available here, being already claimed - /// by selection toggling. + /// button instead. Long-press is deliberately unbound here: it enters + /// selection nowhere in the app, and a hidden gesture is not an + /// affordance. // coverage:ignore-start // showMenu at a pointer position is not drivable from flutter_test without // a real mouse; the menu's single action is a direct call to the same @@ -61,7 +66,6 @@ class MediaLibraryTile extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: onTap, - onLongPress: onLongPress, onSecondaryTapDown: (details) => _showContextMenu(context, details), child: Stack( fit: StackFit.expand, @@ -72,6 +76,8 @@ class MediaLibraryTile extends StatelessWidget { targetSize: const Size(200, 200), fit: BoxFit.cover, ), + if (isSelectionMode && !selected) + Container(color: Colors.black.withValues(alpha: 0.3)), // Top-left: the top-right corner belongs to the selection check. Positioned( top: 4, @@ -108,7 +114,7 @@ class MediaLibraryGrid extends StatelessWidget { required this.onLoadMore, required this.onTileTap, this.selectedIds = const {}, - this.onTileLongPress, + this.isSelectionMode = false, }); final List entries; @@ -119,8 +125,9 @@ class MediaLibraryGrid extends StatelessWidget { /// Ids rendered with the selection overlay. final Set selectedIds; - /// Long-press hook for entering selection mode. - final void Function(MediaLibraryEntry entry)? onTileLongPress; + /// Whether the surface is in multi-select, which can be true with nothing + /// checked. + final bool isSelectionMode; static const double _loadMoreThreshold = 400; @@ -148,10 +155,8 @@ class MediaLibraryGrid extends StatelessWidget { return MediaLibraryTile( entry: entry, selected: selectedIds.contains(entry.item.id), + isSelectionMode: isSelectionMode, onTap: () => onTileTap(entry, index), - onLongPress: onTileLongPress == null - ? null - : () => onTileLongPress!(entry), ); }, ), diff --git a/lib/features/media/presentation/widgets/media_library_grouped_list.dart b/lib/features/media/presentation/widgets/media_library_grouped_list.dart index c341d64129..807d1fe099 100644 --- a/lib/features/media/presentation/widgets/media_library_grouped_list.dart +++ b/lib/features/media/presentation/widgets/media_library_grouped_list.dart @@ -18,7 +18,7 @@ class MediaLibraryGroupedList extends StatelessWidget { required this.onLoadMore, required this.onTileTap, this.selectedIds = const {}, - this.onTileLongPress, + this.isSelectionMode = false, }); final List groups; @@ -29,8 +29,9 @@ class MediaLibraryGroupedList extends StatelessWidget { /// Ids rendered with the selection overlay. final Set selectedIds; - /// Long-press hook for entering selection mode. - final void Function(MediaLibraryEntry entry)? onTileLongPress; + /// Whether the surface is in multi-select, which can be true with nothing + /// checked. + final bool isSelectionMode; static const double _loadMoreThreshold = 400; @@ -93,7 +94,7 @@ class MediaLibraryGroupedList extends StatelessWidget { // Inert while a selection is in progress, matching the tiles below it: // a tap landing a few pixels high must not navigate away from a // half-built selection. - final navigable = diveId != null && selectedIds.isEmpty; + final navigable = diveId != null && !isSelectionMode; rows.add( navigable ? Semantics( @@ -144,10 +145,8 @@ class MediaLibraryGroupedList extends StatelessWidget { return MediaLibraryTile( entry: entry, selected: selectedIds.contains(entry.item.id), + isSelectionMode: isSelectionMode, onTap: () => onTileTap(entry), - onLongPress: onTileLongPress == null - ? null - : () => onTileLongPress!(entry), ); }, ), diff --git a/lib/features/media/presentation/widgets/media_library_toolbar.dart b/lib/features/media/presentation/widgets/media_library_toolbar.dart index 24649dc147..adadab087c 100644 --- a/lib/features/media/presentation/widgets/media_library_toolbar.dart +++ b/lib/features/media/presentation/widgets/media_library_toolbar.dart @@ -7,15 +7,34 @@ import 'package:submersion/features/media/presentation/providers/media_library_p import 'package:submersion/features/media/presentation/providers/media_library_sort_provider.dart'; import 'package:submersion/features/media/presentation/widgets/media_library_filter_sheet.dart'; import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/selection/selection_controller.dart'; import 'package:submersion/shared/widgets/sort_bottom_sheet.dart'; -/// The library's control row: filter, sort, and view mode. +/// The library's control row: filter, sort, select, and view mode. /// /// Every control is fixed-width, which is the point. The chip row this /// replaced was an Expanded horizontal scroller that claimed all free width /// and squeezed the view-mode selector beside it. +/// +/// Fixed widths also mean the row has a hard budget: at 320dp, the narrowest +/// phone the app ships to, three default-density icon buttons plus the +/// view-mode selector overflow by 16px. The icons are compact for that +/// reason, matching the dive media section's header, and a fourth control +/// does not fit without finding space somewhere else. class MediaLibraryToolbar extends ConsumerWidget { - const MediaLibraryToolbar({super.key}); + const MediaLibraryToolbar({ + super.key, + required this.selection, + required this.canSelect, + }); + + /// The library's selection state machine. The Select control is the only + /// way into multi-select: long-press enters selection nowhere in the app. + final SelectionController selection; + + /// Whether there is anything to select. An empty library hides the control + /// rather than offering a mode with no items in it. + final bool canSelect; @override Widget build(BuildContext context, WidgetRef ref) { @@ -30,6 +49,7 @@ class MediaLibraryToolbar extends ConsumerWidget { isLabelVisible: !filter.isEmpty, child: const Icon(Icons.filter_list, size: 20), ), + visualDensity: VisualDensity.compact, tooltip: l10n.media_library_filter_title, onPressed: () => showMediaLibraryFilterSheet(context), ), @@ -39,6 +59,7 @@ class MediaLibraryToolbar extends ConsumerWidget { if (mode == MediaLibraryViewMode.grid) IconButton( icon: const Icon(Icons.sort, size: 20), + visualDensity: VisualDensity.compact, tooltip: l10n.media_library_sort_title, onPressed: () { final sort = ref.read(mediaLibrarySortProvider); @@ -56,6 +77,14 @@ class MediaLibraryToolbar extends ConsumerWidget { ); }, ), + if (canSelect) + IconButton( + key: const ValueKey('enter_selection'), + icon: const Icon(Icons.checklist, size: 20), + visualDensity: VisualDensity.compact, + tooltip: l10n.common_selection_enterTooltip, + onPressed: selection.enterExplicit, + ), const Spacer(), SegmentedButton( showSelectedIcon: false, diff --git a/lib/features/media/presentation/widgets/media_selection_bar.dart b/lib/features/media/presentation/widgets/media_selection_bar.dart index 3417e6a1de..5fb3b00d74 100644 --- a/lib/features/media/presentation/widgets/media_selection_bar.dart +++ b/lib/features/media/presentation/widgets/media_selection_bar.dart @@ -4,16 +4,35 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/presentation/helpers/media_share_helper.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; -import 'package:submersion/features/media/presentation/providers/media_selection_provider.dart'; import 'package:submersion/features/media/presentation/widgets/dive_picker_sheet.dart'; import 'package:submersion/features/media/presentation/widgets/unlink_metadata_warning_dialog.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; - -/// Action bar shown above the library while a selection is active: count, -/// Share, Delete (with confirm), and a clear affordance. +import 'package:submersion/shared/selection/bulk_action.dart'; +import 'package:submersion/shared/selection/selection_app_bar.dart'; +import 'package:submersion/shared/selection/selection_controller.dart'; + +/// The library's contextual bar while a selection is active. +/// +/// The chrome -- count, close, select all, deselect all, and delete tucked +/// into the overflow behind a divider -- comes from the shared +/// [SelectionAppBar], so the library cannot drift from every other selectable +/// surface. This widget contributes only the media-specific bulk actions and +/// the logic behind them. class MediaSelectionBar extends ConsumerWidget { - const MediaSelectionBar({super.key, required this.selectedItems}); + const MediaSelectionBar({ + super.key, + required this.controller, + required this.selectableIds, + required this.selectedItems, + }); + + /// The library's selection state machine, shared with the view that hosts + /// this bar. + final SelectionController controller; + + /// Every id currently on screen, which is what Select All checks. + final List selectableIds; /// The currently selected items, resolved by the caller from the visible /// entries so share/delete operate on real MediaItems. @@ -46,7 +65,7 @@ class MediaSelectionBar extends ConsumerWidget { await ref .read(mediaDeletionCoordinatorProvider) .deleteMultipleMedia(selectedItems.map((m) => m.id).toList()); - ref.read(mediaSelectionProvider.notifier).clear(); + controller.exit(); } List get _ids => selectedItems.map((m) => m.id).toList(); @@ -80,7 +99,7 @@ class MediaSelectionBar extends ConsumerWidget { } await service.unlinkFromDive(ids); - ref.read(mediaSelectionProvider.notifier).clear(); + controller.exit(); } Future _unlinkFromSite(BuildContext context, WidgetRef ref) async { @@ -99,85 +118,58 @@ class MediaSelectionBar extends ConsumerWidget { } await service.unlinkFromSite(ids); - ref.read(mediaSelectionProvider.notifier).clear(); + controller.exit(); } Future _moveToDive(BuildContext context, WidgetRef ref) async { final diveId = await showDivePickerSheet(context); if (diveId == null) return; await ref.read(mediaRepositoryProvider).reassignMediaToDive(_ids, diveId); - ref.read(mediaSelectionProvider.notifier).clear(); + controller.exit(); } @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; final anyDiveLinked = selectedItems.any((m) => m.diveId != null); final anySiteLinked = selectedItems.any((m) => m.siteId != null); - return Material( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close), - tooltip: context.l10n.common_action_cancel, - onPressed: () => - ref.read(mediaSelectionProvider.notifier).clear(), - ), - Text( - context.l10n.media_library_selectedCount(selectedItems.length), - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(width: 8), - // The action set grows with selection context; scroll instead of - // overflowing on narrow layouts. - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - if (anyDiveLinked) - TextButton.icon( - icon: const Icon(Icons.link_off), - label: Text(context.l10n.media_library_unlinkSelected), - onPressed: () => _unlinkFromDive(context, ref), - ), - if (anySiteLinked) - TextButton.icon( - icon: const Icon(Icons.location_off), - label: Text(context.l10n.media_library_unlinkFromSite), - onPressed: () => _unlinkFromSite(context, ref), - ), - TextButton.icon( - icon: const Icon(Icons.drive_file_move_outline), - label: Text(context.l10n.media_library_moveToDive), - onPressed: selectedItems.isEmpty - ? null - : () => _moveToDive(context, ref), - ), - TextButton.icon( - icon: const Icon(Icons.share), - label: Text(context.l10n.common_action_share), - onPressed: selectedItems.isEmpty - ? null - : () => shareMediaItems(context, ref, selectedItems), - ), - TextButton.icon( - icon: const Icon(Icons.delete_outline), - label: Text(context.l10n.common_action_delete), - onPressed: selectedItems.isEmpty - ? null - : () => _deleteSelected(context, ref), - ), - ], - ), - ), - ), - ], + // Share and Move come first so they hold the same two inline slots + // whatever the selection contains; the conditional unlinks follow, which + // keeps the row that can delete media out of the leftmost reach. + return SelectionAppBar( + controller: controller, + selectableIds: selectableIds, + shell: SelectionBarShell.pane, + onDelete: () => _deleteSelected(context, ref), + actions: [ + BulkAction( + id: 'share', + icon: Icons.share, + label: l10n.common_action_share, + onInvoke: () => shareMediaItems(context, ref, selectedItems), ), - ), + BulkAction( + id: 'move_to_dive', + icon: Icons.drive_file_move_outline, + label: l10n.media_library_moveToDive, + onInvoke: () => _moveToDive(context, ref), + ), + if (anyDiveLinked) + BulkAction( + id: 'unlink', + icon: Icons.link_off, + label: l10n.media_library_unlinkSelected, + onInvoke: () => _unlinkFromDive(context, ref), + ), + if (anySiteLinked) + BulkAction( + id: 'unlink_site', + icon: Icons.location_off, + label: l10n.media_library_unlinkFromSite, + onInvoke: () => _unlinkFromSite(context, ref), + ), + ], ); } } diff --git a/test/features/media/presentation/media_library_grouped_list_test.dart b/test/features/media/presentation/media_library_grouped_list_test.dart index c9fea6055a..f5ad494892 100644 --- a/test/features/media/presentation/media_library_grouped_list_test.dart +++ b/test/features/media/presentation/media_library_grouped_list_test.dart @@ -60,6 +60,7 @@ void main() { VoidCallback? onLoadMore, void Function(MediaLibraryEntry)? onTileTap, Set selectedIds = const {}, + bool isSelectionMode = false, }) { return ProviderScope( overrides: _badgeOverrides().cast(), @@ -74,6 +75,7 @@ void main() { onLoadMore: onLoadMore ?? () {}, onTileTap: onTileTap ?? (_) {}, selectedIds: selectedIds, + isSelectionMode: isSelectionMode, ), ), ), @@ -211,6 +213,7 @@ void main() { diveGroup(diveId: 'd1', diveNumber: 9, entries: [entry('a')]), ], selectedIds: const {'a'}, + isSelectionMode: true, ), ); await tester.pump(); @@ -221,6 +224,23 @@ void main() { expect(find.text('#9'), findsOneWidget); }); + testWidgets('a dive header is inert in selection mode with nothing checked', ( + tester, + ) async { + await tester.pumpWidget( + host([ + diveGroup(diveId: 'd1', diveNumber: 9, entries: [entry('a')]), + ], isSelectionMode: true), + ); + await tester.pump(); + + // The Select button enters the mode with an empty selection, a state the + // old id-set could not represent. Keying header navigation off "something + // is checked" would leave the header live for exactly that first tap. + expect(find.byType(InkWell), findsNothing); + expect(find.text('#9'), findsOneWidget); + }); + testWidgets('a linked dive with no label still shows a tappable header', ( tester, ) async { diff --git a/test/features/media/presentation/media_library_selection_contract_test.dart b/test/features/media/presentation/media_library_selection_contract_test.dart new file mode 100644 index 0000000000..4af69cf3f7 --- /dev/null +++ b/test/features/media/presentation/media_library_selection_contract_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/media/data/services/media_source_resolver_registry.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_library_filter.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/services/media_source_resolver.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; +import 'package:submersion/features/media/presentation/pages/media_library_view.dart'; +import 'package:submersion/features/media/presentation/providers/media_library_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_library_grid.dart'; +import 'package:submersion/features/settings/data/repositories/app_settings_repository.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../helpers/selection_contract.dart'; + +class _UnavailableResolver implements MediaSourceResolver { + @override + MediaSourceType get sourceType => MediaSourceType.localFile; + @override + bool canResolveOnThisDevice(MediaItem item) => true; + @override + Future resolve(MediaItem item) async => + const UnavailableData(kind: UnavailableKind.notFound); + @override + Future resolveThumbnail( + MediaItem item, { + required Size target, + }) => resolve(item); + @override + Future extractMetadata(MediaItem item) async => null; + @override + Future verify(MediaItem item) async => VerifyResult.available; +} + +/// Library notifier whose page can be replaced mid-test, which is how the +/// contract's filter step narrows the visible set. +class _SeededLibraryNotifier extends StateNotifier + implements MediaLibraryNotifier { + _SeededLibraryNotifier(super.state); + + void seed(List entries) { + state = MediaLibraryState(entries: entries); + } + + @override + Future loadFirstPage() async {} + + @override + Future loadMore() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSettingsRepo extends AppSettingsRepository { + @override + Future getRawSetting(String key) async => null; + + @override + Future setRawSetting(String key, String value) async {} +} + +MediaLibraryEntry _entry(String id) => MediaLibraryEntry( + item: MediaItem( + id: id, + mediaType: MediaType.photo, + sourceType: MediaSourceType.localFile, + filePath: '/tmp/$id', + localPath: '/tmp/$id', + takenAt: DateTime(2026, 6, 1), + createdAt: DateTime(2026, 6, 1), + updatedAt: DateTime(2026, 6, 1), + ), +); + +void main() { + testWidgets('MediaLibraryView honours the selection contract', ( + tester, + ) async { + late _SeededLibraryNotifier notifier; + + Widget host() { + notifier = _SeededLibraryNotifier( + MediaLibraryState(entries: [_entry('a'), _entry('b'), _entry('c')]), + ); + return ProviderScope( + overrides: [ + mediaLibraryNotifierProvider.overrideWith((ref) => notifier), + appSettingsRepositoryProvider.overrideWithValue(_FakeSettingsRepo()), + mediaSourceResolverRegistryProvider.overrideWithValue( + MediaSourceResolverRegistry({ + MediaSourceType.localFile: _UnavailableResolver(), + }), + ), + ], + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: MediaLibraryView()), + ), + ); + } + + await verifySelectionContract( + tester, + build: host, + selectButton: find.byKey(const ValueKey('enter_selection')), + firstRow: find.byType(MediaLibraryTile).first, + // The library draws a check badge over the thumbnail rather than a + // Checkbox, so it opts out of that one assertion only. + indicator: CheckedIndicator.custom, + applyFilter: (tester) async => notifier.seed([_entry('a')]), + visibleAfterFilter: 1, + ); + }); +} diff --git a/test/features/media/presentation/media_library_toolbar_test.dart b/test/features/media/presentation/media_library_toolbar_test.dart index b7af60135c..37fc74fd29 100644 --- a/test/features/media/presentation/media_library_toolbar_test.dart +++ b/test/features/media/presentation/media_library_toolbar_test.dart @@ -12,6 +12,7 @@ import 'package:submersion/features/settings/data/repositories/app_settings_repo import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/shared/selection/selection_controller.dart'; /// Both the view-mode notifier and the sort notifier read and WRITE app /// settings. Without this override they reach the real repository, and the @@ -31,8 +32,11 @@ class _FakeSettingsRepo extends AppSettingsRepository { void main() { late ProviderContainer container; + late SelectionController selection; setUp(() { + selection = SelectionController(); + addTearDown(selection.dispose); container = ProviderContainer( overrides: [ sitesProvider.overrideWith((ref) async => []), @@ -43,22 +47,24 @@ void main() { addTearDown(container.dispose); }); - Widget host() => UncontrolledProviderScope( + Widget host({bool canSelect = true}) => UncontrolledProviderScope( container: container, - child: const MaterialApp( - locale: Locale('en'), + child: MaterialApp( + locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: MediaLibraryToolbar()), + home: Scaffold( + body: MediaLibraryToolbar(selection: selection, canSelect: canSelect), + ), ), ); - Future pump(WidgetTester tester) async { + Future pump(WidgetTester tester, {bool canSelect = true}) async { tester.view.physicalSize = const Size(1600, 1200); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget(host()); + await tester.pumpWidget(host(canSelect: canSelect)); await tester.pumpAndSettle(); } @@ -116,4 +122,47 @@ void main() { expect(find.text('Filter media'), findsOneWidget); }); + + testWidgets('the Select control enters selection mode with nothing checked', ( + tester, + ) async { + await pump(tester); + + await tester.tap(find.byKey(const ValueKey('enter_selection'))); + await tester.pumpAndSettle(); + + expect(selection.value.isActive, isTrue); + expect(selection.value.checkedIds, isEmpty); + expect( + selection.value.enteredExplicitly, + isTrue, + reason: 'a deliberate entry must survive unchecking the last item', + ); + }); + + testWidgets('the Select control is absent when there is nothing to select', ( + tester, + ) async { + await pump(tester, canSelect: false); + + expect(find.byKey(const ValueKey('enter_selection')), findsNothing); + }); + + // Every control in this row is fixed-width, so a new one is spent budget + // rather than borrowed space. 320dp is the narrowest phone the app ships + // to, and a RenderFlex overflow there is a red banner, not a squeeze. + testWidgets('the row still fits the narrowest supported phone', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const ValueKey('enter_selection')), findsOneWidget); + }); } diff --git a/test/features/media/presentation/media_selection_test.dart b/test/features/media/presentation/media_selection_test.dart index 0b5cb0fa3c..bda116cea3 100644 --- a/test/features/media/presentation/media_selection_test.dart +++ b/test/features/media/presentation/media_selection_test.dart @@ -15,10 +15,10 @@ import 'package:submersion/features/dive_log/presentation/providers/dive_reposit import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/media/data/repositories/media_repository.dart'; import 'package:submersion/features/media/presentation/pages/media_library_view.dart'; +import 'package:submersion/features/media/presentation/pages/media_viewer_page.dart'; import 'package:submersion/features/media/presentation/providers/media_library_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; -import 'package:submersion/features/media/presentation/providers/media_selection_provider.dart'; import 'package:submersion/features/media/presentation/widgets/media_library_grid.dart'; import 'package:submersion/features/media_store/data/media_deletion_coordinator.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; @@ -181,23 +181,6 @@ MediaLibraryEntry entry(String id, {String? diveId, String? siteId}) => ); void main() { - group('MediaSelectionNotifier', () { - test('toggle adds then removes an id; clear empties', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(mediaSelectionProvider.notifier); - - notifier.toggle('a'); - expect(container.read(mediaSelectionProvider), {'a'}); - notifier.toggle('b'); - expect(container.read(mediaSelectionProvider), {'a', 'b'}); - notifier.toggle('a'); - expect(container.read(mediaSelectionProvider), {'b'}); - notifier.clear(); - expect(container.read(mediaSelectionProvider), isEmpty); - }); - }); - group('selection UI', () { late _RecordingDeletionCoordinator coordinator; late _RecordingMediaRepo mediaRepo; @@ -231,6 +214,24 @@ void main() { ); } + /// Enters selection mode through the Select control, then checks the + /// tiles at [indices]. There is no gesture entry to fall back on: the + /// control is the whole affordance. + Future selectTiles(WidgetTester tester, List indices) async { + await tester.tap(find.byKey(const ValueKey('enter_selection'))); + await tester.pumpAndSettle(); + for (final index in indices) { + await tester.tap(find.byType(MediaLibraryTile).at(index)); + await tester.pumpAndSettle(); + } + } + + /// Opens the bar's overflow menu, where the baseline delete lives. + Future openOverflow(WidgetTester tester) async { + await tester.tap(find.byKey(const ValueKey('selection_overflow'))); + await tester.pumpAndSettle(); + } + // Unlinking removes the media from the library outright: the row, the // cloud proxies and the thumbnails. Only the ORIGINAL source file is // spared, and nothing on this path reads or writes its path. @@ -240,12 +241,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MediaLibraryTile).at(1)); - await tester.pumpAndSettle(); + await selectTiles(tester, [0, 1]); - await tester.tap(find.text('Unlink')); + await tester.tap(find.byKey(const ValueKey('selection_action_unlink'))); await tester.pumpAndSettle(); expect(coordinator.deleted.toSet(), {'a', 'b'}); @@ -254,10 +252,11 @@ void main() { isEmpty, reason: 'nothing here is site media, so nothing is merely detached', ); - final container = ProviderScope.containerOf( - tester.element(find.byType(MediaLibraryView)), + expect( + find.byKey(const ValueKey('selection_exit')), + findsNothing, + reason: 'a completed bulk action leaves selection mode', ); - expect(container.read(mediaSelectionProvider), isEmpty); }); testWidgets('Unlink keeps media a dive site still needs', (tester) async { @@ -268,12 +267,9 @@ void main() { mediaRepo.siteLinkedIds.add('b'); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MediaLibraryTile).at(1)); - await tester.pumpAndSettle(); + await selectTiles(tester, [0, 1]); - await tester.tap(find.text('Unlink')); + await tester.tap(find.byKey(const ValueKey('selection_action_unlink'))); await tester.pumpAndSettle(); expect(coordinator.deleted, ['a']); @@ -289,12 +285,9 @@ void main() { mediaRepo.withUserMetadata.add('a'); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MediaLibraryTile).at(1)); - await tester.pumpAndSettle(); + await selectTiles(tester, [0, 1]); - await tester.tap(find.text('Unlink')); + await tester.tap(find.byKey(const ValueKey('selection_action_unlink'))); await tester.pumpAndSettle(); expect(find.text('Unlink and discard details?'), findsOneWidget); @@ -316,10 +309,9 @@ void main() { mediaRepo.withUserMetadata.add('a'); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); + await selectTiles(tester, [0]); - await tester.tap(find.text('Unlink')); + await tester.tap(find.byKey(const ValueKey('selection_action_unlink'))); await tester.pumpAndSettle(); // The dialog's confirm reuses the bar's own "Unlink" label, so target // the one inside the AlertDialog rather than the bar behind it. @@ -340,10 +332,9 @@ void main() { await tester.pumpWidget(host([entry('a', diveId: 'd1')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); + await selectTiles(tester, [0]); - await tester.tap(find.text('Unlink')); + await tester.tap(find.byKey(const ValueKey('selection_action_unlink'))); await tester.pumpAndSettle(); expect(find.byType(AlertDialog), findsNothing); @@ -355,18 +346,25 @@ void main() { await tester.pumpWidget(host([entry('a'), entry('b', siteId: 's1')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - expect(find.text('Unlink from site'), findsNothing); + await selectTiles(tester, [0]); + expect( + find.byKey(const ValueKey('selection_action_unlink_site')), + findsNothing, + ); await tester.tap(find.byType(MediaLibraryTile).at(1)); await tester.pumpAndSettle(); - expect(find.text('Unlink from site'), findsOneWidget); + expect( + find.byKey(const ValueKey('selection_action_unlink_site')), + findsOneWidget, + ); // Only the site-linked id reaches the service, and with no dive // holding it the row leaves the library rather than lingering // unlinked. - await tester.tap(find.text('Unlink from site')); + await tester.tap( + find.byKey(const ValueKey('selection_action_unlink_site')), + ); await tester.pumpAndSettle(); expect(coordinator.deleted, ['b']); expect(mediaRepo.unlinkedFromSite, isEmpty); @@ -379,9 +377,10 @@ void main() { await tester.pumpAndSettle(); mediaRepo.withUserMetadata.add('b'); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Unlink from site')); + await selectTiles(tester, [0]); + await tester.tap( + find.byKey(const ValueKey('selection_action_unlink_site')), + ); await tester.pumpAndSettle(); expect(find.text('Unlink and discard details?'), findsOneWidget); @@ -402,15 +401,10 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MediaLibraryTile).at(1)); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MediaLibraryTile).at(2)); - await tester.pumpAndSettle(); + await selectTiles(tester, [0, 1, 2]); expect(find.text('3 selected'), findsOneWidget); - await tester.tap(find.text('Unlink')); + await tester.tap(find.byKey(const ValueKey('selection_action_unlink'))); await tester.pumpAndSettle(); // Only the dive-linked id is acted on: the unlinked row and the @@ -422,10 +416,11 @@ void main() { await tester.pumpWidget(host([entry('a')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); + await selectTiles(tester, [0]); - await tester.tap(find.text('Move to dive')); + await tester.tap( + find.byKey(const ValueKey('selection_action_move_to_dive')), + ); await tester.pumpAndSettle(); await tester.tap(find.textContaining('#2')); await tester.pumpAndSettle(); @@ -434,18 +429,65 @@ void main() { expect(mediaRepo.reassigned?.$2, 'dive-2'); }); - testWidgets('long-press enters selection mode and shows the bar', ( + testWidgets('the Select control shows the bar and the media actions', ( tester, ) async { await tester.pumpWidget(host([entry('a'), entry('b')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); + await selectTiles(tester, [0]); expect(find.text('1 selected'), findsOneWidget); - expect(find.text('Delete'), findsOneWidget); - expect(find.text('Share'), findsOneWidget); + expect( + find.byKey(const ValueKey('selection_action_share')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('selection_action_move_to_dive')), + findsOneWidget, + ); + // Select all and deselect all come from the shared bar, so the library + // cannot ship without them. + expect( + find.byKey(const ValueKey('selection_select_all')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('selection_deselect_all')), + findsOneWidget, + ); + }); + + // The dive media section swaps its whole header for the bar. The library + // keeps filter and sort reachable -- narrowing the list and then hitting + // Select All is a real flow -- but the control that opens a mode already + // open is dead weight. + testWidgets('the Select control hides while the bar is up', (tester) async { + await tester.pumpWidget(host([entry('a'), entry('b')])); + await tester.pumpAndSettle(); + + await selectTiles(tester, [0]); + + expect(find.byKey(const ValueKey('enter_selection')), findsNothing); + expect(find.byIcon(Icons.filter_list), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('selection_exit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('enter_selection')), findsOneWidget); + }); + + // The old entry gesture is gone everywhere, and nothing replaced it: a + // hold now resolves as a plain tap, which opens the viewer rather than + // starting a selection nobody asked for. + testWidgets('a long press does not enter selection mode', (tester) async { + await tester.pumpWidget(host([entry('a'), entry('b')])); + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(MediaLibraryTile).first); + await tester.pump(); + + expect(find.byKey(const ValueKey('selection_exit')), findsNothing); }); testWidgets('delete confirms then calls the deletion chain and clears', ( @@ -454,13 +496,13 @@ void main() { await tester.pumpWidget(host([entry('a'), entry('b')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MediaLibraryTile).at(1)); - await tester.pumpAndSettle(); + await selectTiles(tester, [0, 1]); expect(find.text('2 selected'), findsOneWidget); - await tester.tap(find.text('Delete')); + // Delete never renders inline: destroying a whole selection takes a + // deliberate open-then-choose, a safety property of the shared bar. + await openOverflow(tester); + await tester.tap(find.byKey(const ValueKey('selection_delete'))); await tester.pumpAndSettle(); // Confirm dialog expect(find.text('Delete 2 items?'), findsOneWidget); @@ -468,10 +510,11 @@ void main() { await tester.pumpAndSettle(); expect(coordinator.deleted.toSet(), {'a', 'b'}); - final container = ProviderScope.containerOf( - tester.element(find.byType(MediaLibraryView)), + expect( + find.byKey(const ValueKey('selection_exit')), + findsNothing, + reason: 'a completed bulk action leaves selection mode', ); - expect(container.read(mediaSelectionProvider), isEmpty); }); testWidgets('tap in selection mode toggles instead of opening viewer', ( @@ -480,30 +523,35 @@ void main() { await tester.pumpWidget(host([entry('a'), entry('b')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - // Tap the already-selected tile: deselects, bar disappears. + await selectTiles(tester, [0]); + expect(find.text('1 selected'), findsOneWidget); + + // Unchecking the last item leaves the mode standing: the user asked + // for it with the Select control, so only they can end it. The old + // id-set could not tell a deliberate entry from an incidental one and + // dropped the bar here. await tester.tap(find.byType(MediaLibraryTile).first); await tester.pumpAndSettle(); - expect(find.text('Delete'), findsNothing); + expect(find.text('0 selected'), findsOneWidget); + expect(find.byType(MediaViewerPage), findsNothing); }); testWidgets('the close button leaves selection mode', (tester) async { await tester.pumpWidget(host([entry('a'), entry('b')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - expect(find.text('Delete'), findsOneWidget); + await selectTiles(tester, [0]); + expect(find.text('1 selected'), findsOneWidget); - await tester.tap(find.byIcon(Icons.close)); + await tester.tap(find.byKey(const ValueKey('selection_exit'))); await tester.pumpAndSettle(); - expect(find.text('Delete'), findsNothing); - final container = ProviderScope.containerOf( - tester.element(find.byType(MediaLibraryView)), + expect(find.text('1 selected'), findsNothing); + expect( + find.byKey(const ValueKey('selection_exit')), + findsNothing, + reason: 'a completed bulk action leaves selection mode', ); - expect(container.read(mediaSelectionProvider), isEmpty); }); testWidgets('cancelling the delete confirmation deletes nothing', ( @@ -512,9 +560,9 @@ void main() { await tester.pumpWidget(host([entry('a'), entry('b')])); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Delete')); + await selectTiles(tester, [0]); + await openOverflow(tester); + await tester.tap(find.byKey(const ValueKey('selection_delete'))); await tester.pumpAndSettle(); await tester.tap(find.text('Cancel')); @@ -523,7 +571,7 @@ void main() { expect(coordinator.deleted, isEmpty); // Still in selection mode: cancelling the dialog is not cancelling // the selection. - expect(find.text('Delete'), findsOneWidget); + expect(find.text('1 selected'), findsOneWidget); }); }); } diff --git a/test/features/media/presentation/widgets/media_library_tile_test.dart b/test/features/media/presentation/widgets/media_library_tile_test.dart index fff9f3bd5b..e8c190e07e 100644 --- a/test/features/media/presentation/widgets/media_library_tile_test.dart +++ b/test/features/media/presentation/widgets/media_library_tile_test.dart @@ -44,14 +44,15 @@ void main() { Intl.defaultLocale = previousDefaultLocale; }); - /// Counters as a mutable list so one pump is enough: a record would - /// snapshot the values at return time and never observe a later gesture. + /// Counter as a mutable list so one pump is enough: a record would + /// snapshot the value at return time and never observe a later gesture. Future> pump( WidgetTester tester, MediaLibraryEntry entry, { bool selected = false, + bool isSelectionMode = false, }) async { - final counts = [0, 0]; // taps, long presses + final counts = [0]; // taps await tester.pumpWidget( ProviderScope( overrides: [ @@ -73,8 +74,8 @@ void main() { child: MediaLibraryTile( entry: entry, selected: selected, + isSelectionMode: isSelectionMode, onTap: () => counts[0]++, - onLongPress: () => counts[1]++, ), ), ), @@ -100,18 +101,31 @@ void main() { expect(find.byKey(const Key('media-status-badge')), findsNothing); }); - // Selection must not regress: long-press is this tile's way into - // multi-select, and the badge now sits in the same Stack. - testWidgets('tap and long-press still reach their callbacks', (tester) async { + // Selection must not regress: the tile's tap is what toggles a checked + // item, and the status badge sits in the same Stack. + testWidgets('a tap reaches the callback', (tester) async { final counts = await pump(tester, _entry(uploaded: true)); await tester.tap(find.byType(MediaLibraryTile)); await tester.pumpAndSettle(); + + expect(counts[0], 1); + }); + + // Long-press enters selection nowhere in the app. With the handler gone + // there is no upper duration bound on TapGestureRecognizer, so a hold + // resolves as an ordinary tap on release -- it must not be silently + // swallowed, and it must not do anything a tap would not. + testWidgets('a hold resolves as an ordinary tap, selecting nothing', ( + tester, + ) async { + final counts = await pump(tester, _entry(uploaded: true)); + await tester.longPress(find.byType(MediaLibraryTile)); await tester.pumpAndSettle(); - expect(counts[0], 1, reason: 'tap'); - expect(counts[1], 1, reason: 'long press'); + expect(counts[0], 1, reason: 'the hold fell through to onTap'); + expect(find.byIcon(Icons.check_circle), findsNothing); }); testWidgets('the selection check still renders when selected', ( @@ -121,4 +135,41 @@ void main() { expect(find.byIcon(Icons.check_circle), findsOneWidget); }); + + /// The scrim drawn over unchecked tiles while the mode is active. + Finder dimScrim() => find.byWidgetPredicate( + (widget) => widget is Container && widget.color == _dimColor, + ); + + // Selection mode can be active with nothing checked, which is what the + // Select control produces. Without the scrim that state is invisible: the + // grid looks exactly as it does outside the mode, and the next tap does + // something the user did not expect. + testWidgets('an unchecked tile dims while selection mode is active', ( + tester, + ) async { + await pump(tester, _entry(uploaded: true), isSelectionMode: true); + + expect(dimScrim(), findsOneWidget); + }); + + testWidgets('a checked tile is not dimmed', (tester) async { + await pump( + tester, + _entry(uploaded: true), + isSelectionMode: true, + selected: true, + ); + + expect(dimScrim(), findsNothing); + }); + + testWidgets('nothing dims outside selection mode', (tester) async { + await pump(tester, _entry(uploaded: true)); + + expect(dimScrim(), findsNothing); + }); } + +/// Kept in step with the tile's own scrim colour. +final Color _dimColor = Colors.black.withValues(alpha: 0.3); From 80cda428fbfcdbda8c5d02089367e86964fc18de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 24 Aug 2026 21:26:26 +0000 Subject: [PATCH 008/122] chore: bump version to 1.7.6+123 Opens the next beta train after promoting v1.7.5.6772; the App Store closes a version train on release, so betas cannot continue on the promoted marketing version. --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 09082261e9..def9ea44d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: submersion description: An open-source dive logging application for scuba divers. publish_to: 'none' -version: 1.7.5+122 +version: 1.7.6+123 environment: sdk: ^3.10.0 From 8dca217e6de4543bd18f831eae3f4e44f2d93c08 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 17:58:16 -0400 Subject: [PATCH 009/122] fix(release): resubmit after a rejection instead of skipping Promote run 32779335838 published 1.7.5 and submitted it on macOS, but the iOS submit lane logged "1.7.4 is already in review; skipping submission of 1.7.5" and exited green. App Store Connect showed 1.7.4 as Rejected, not in review. Both readings were right. Rejection closes the review but not the review SUBMISSION: it stays open in UNRESOLVED_ISSUES, which is one of the three states fastlane's get_in_progress_review_submission matches. The guard's version-agnostic first check read that as "Apple is holding a build" and short-circuited, so ALREADY_SUBMITTED_STATES, which deliberately omits every rejected state because "they all need a fresh submission, which is exactly what this lane is for", never got to run. The two halves of the guard disagreed about rejection and the half that never sees a version string won. Relaxing that check alone is not enough. deliver runs the identical lookup in create_review_submission and calls user_error! on any hit, but only after it has renamed the editable version and uploaded metadata, so the lane would have traded a clean skip for a red run and a half-written version. The rejected submission has to be retired first. So: read the submission state, treat only an explicit UNRESOLVED_ISSUES as non-blocking, cancel that dead submission, and poll until App Store Connect stops reporting it before going on to the editable-version check. WAITING_FOR_ REVIEW and IN_REVIEW still block exactly as before; that protection is what run 31903095751 bought and it is untouched. Unknown fails closed. Only a literal UNRESOLVED_ISSUES lowers the guard or triggers a cancel, so an unreadable state, an empty one, or a state Apple adds later all keep the old blocking behaviour. A cancel that does not take effect blocks too, rather than falling through into deliver's error. Note what this changes operationally: a rejection used to force a human to look before anything was resubmitted. The promote job now resubmits on its own, which matches Play and the appcast, and puts the "did we actually fix the rejection?" judgement entirely on the release owner. The guard test grows fakes for submission state and cancellation, five decision cases and four wrapper cases. Reverting just the new condition reproduces run 32779335838's message verbatim, on both platforms. --- ios/fastlane/Fastfile | 114 +++++++++-- macos/fastlane/Fastfile | 114 +++++++++-- scripts/release/fastlane_submit_guard_test.rb | 184 +++++++++++++++++- 3 files changed, 382 insertions(+), 30 deletions(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 6b1dae4643..1a97f2e112 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -52,6 +52,21 @@ ALREADY_SUBMITTED_STATES = [ "ACCEPTED", ].freeze +# Apple's name for "we reviewed this and found problems", ie a rejection. It is +# a REVIEW SUBMISSION state, not a version state, and the distinction is the +# whole point of the handling below: App Store Connect shows the version as +# Rejected while the submission that carried it stays OPEN in this state until +# someone clears it. +REJECTED_REVIEW_STATE = "UNRESOLVED_ISSUES" + +# How long to wait for App Store Connect to retire a cancelled submission. +# Cancelling moves it to CANCELING, which is not a state the in-progress +# lookup matches, but the transition is not documented as synchronous, and +# submitting into a half-cancelled submission is exactly the kind of ambiguity +# this guard exists to avoid. +CANCELLED_SUBMISSION_POLL_ATTEMPTS = 10 +CANCELLED_SUBMISSION_POLL_INTERVAL = 3 + platform :ios do # ============================================================================ # Screenshot Lanes @@ -213,10 +228,20 @@ platform :ios do # Note what is deliberately NOT blocking: an editable version carrying an # older version string. Renaming that forward is legitimate, and is how the # recovery from that incident worked. + # + # Nor is a REJECTION blocking, which promote run 32779335838 showed the hard + # way. Apple rejected iOS 1.7.4, but a rejected submission does not close: it + # sits in UNRESOLVED_ISSUES, which the in-progress lookup matches, so the + # version-agnostic check above skipped 1.7.5 and reported 1.7.4 as "already + # in review" while App Store Connect showed it as Rejected. Resubmitting + # after a rejection is precisely what this lane is for, and + # ALREADY_SUBMITTED_STATES already says so; the short-circuit above simply + # never let that decision run. Only an EXPLICIT UNRESOLVED_ISSUES lowers the + # block - an unreadable or unrecognised state keeps the old behaviour. def submission_skip_reason(app_version:, review_in_progress:, - review_version: nil, edit_version: nil, - edit_state: nil) - if review_in_progress + review_version: nil, review_state: nil, + edit_version: nil, edit_state: nil) + if review_in_progress && review_state != REJECTED_REVIEW_STATE held_by = review_version ? "#{review_version} is" : "another version is" return "#{held_by} already in review; skipping submission of " \ "#{app_version}. App Store Connect allows one submission at a " \ @@ -247,7 +272,8 @@ platform :ios do # app_store_connect_api_key action, whose set_spaceship_token defaults to # true, so Spaceship::ConnectAPI.token is already set by the time a lane # calls this. - def submission_blocker(app_version, platform) + def submission_blocker(app_version, platform, + poll_interval: CANCELLED_SUBMISSION_POLL_INTERVAL) app = Spaceship::ConnectAPI::App.find( CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier), ) @@ -265,18 +291,38 @@ platform :ios do includes: "appStoreVersionForReview", ) - # Short-circuit. An in-progress submission is already a block, so the - # editable version cannot change the answer. Skipping the second lookup is - # partly about latency, but mostly about failure surface: this method - # deliberately lets App Store Connect errors propagate, so a call made - # after the answer is known could fail the lane at the moment we already - # knew the safe outcome. Blocking beats erroring. + # Short-circuit. A submission Apple is actually working on is already a + # block, so the editable version cannot change the answer. Skipping the + # second lookup is partly about latency, but mostly about failure surface: + # this method deliberately lets App Store Connect errors propagate, so a + # call made after the answer is known could fail the lane at the moment we + # already knew the safe outcome. Blocking beats erroring. unless submission.nil? - return submission_skip_reason( + reason = submission_skip_reason( app_version: app_version, review_in_progress: true, + review_state: review_submission_state(submission), review_version: review_submission_version(submission), ) + return reason unless reason.nil? + + # Only a rejection gets past that, and a rejected submission has to be + # retired before anything else can be submitted: deliver runs the same + # in-progress lookup in create_review_submission and hard-errors on it, + # but only AFTER it has renamed the editable version and uploaded + # metadata. Clearing it here keeps that half-finished state from ever + # existing. Nothing is withdrawn from Apple by this: the review is over, + # which is what UNRESOLVED_ISSUES means. + cleared = clear_rejected_submission( + app, mapped, submission, poll_interval: poll_interval + ) + + unless cleared + return "the rejected review submission was cancelled but App Store " \ + "Connect still reports it in progress; skipping submission of " \ + "#{app_version}. Re-run once it clears, or remove it from " \ + "review in App Store Connect." + end end version = app.get_edit_app_store_version(platform: mapped) @@ -303,6 +349,47 @@ platform :ios do nil end + # The state of a review submission, or nil when it cannot be read. + # + # Read defensively for the same reason as the version above, but note the + # consequence is inverted: an unknown version only costs a vaguer message, + # while an unknown state decides whether CI may cancel a submission. nil is + # therefore the SAFE answer here, because only an explicit + # REJECTED_REVIEW_STATE lowers the block or triggers a cancel. + def review_submission_state(submission) + return nil if submission.nil? + + submission.state + rescue StandardError + nil + end + + # Cancels a rejected review submission and waits for App Store Connect to + # stop reporting it. True when the road is clear. + # + # A cancel that raises is deliberately NOT rescued, matching the rest of this + # guard: an unexpected App Store Connect error must stop the lane, not be + # read as permission to carry on. + def clear_rejected_submission(app, mapped, submission, + poll_interval: CANCELLED_SUBMISSION_POLL_INTERVAL) + held = review_submission_version(submission) + UI.important( + "#{held || 'A previous version'} was rejected and its review submission " \ + "is still open; cancelling it so this version can be submitted." + ) + + submission.cancel_submission + + CANCELLED_SUBMISSION_POLL_ATTEMPTS.times do |attempt| + return true if app.get_in_progress_review_submission(platform: mapped).nil? + + last = attempt == CANCELLED_SUBMISSION_POLL_ATTEMPTS - 1 + sleep(poll_interval) if poll_interval.positive? && !last + end + + false + end + # Loads API key from environment variables or falls back to api_key.json def load_api_key # Check for environment variables first (preferred for CI) @@ -572,7 +659,10 @@ platform :ios do # Makes the lane a no-op once Apple has this version, or any other version, # so a re-dispatched promotion does not fail on the leg that already - # succeeded and cannot rename a submission that is already under review. + # succeeded and cannot rename a submission that is already under review. A + # REJECTED version is not "Apple has it": rejection leaves the submission + # open in UNRESOLVED_ISSUES, and the blocker retires that so this run can + # resubmit, which is what the lane is for. if (reason = submission_blocker(app_version, "ios")) UI.important("ios: #{reason}") next diff --git a/macos/fastlane/Fastfile b/macos/fastlane/Fastfile index 0e9c5c3fa8..13085ec924 100644 --- a/macos/fastlane/Fastfile +++ b/macos/fastlane/Fastfile @@ -52,6 +52,21 @@ ALREADY_SUBMITTED_STATES = [ "ACCEPTED", ].freeze +# Apple's name for "we reviewed this and found problems", ie a rejection. It is +# a REVIEW SUBMISSION state, not a version state, and the distinction is the +# whole point of the handling below: App Store Connect shows the version as +# Rejected while the submission that carried it stays OPEN in this state until +# someone clears it. +REJECTED_REVIEW_STATE = "UNRESOLVED_ISSUES" + +# How long to wait for App Store Connect to retire a cancelled submission. +# Cancelling moves it to CANCELING, which is not a state the in-progress +# lookup matches, but the transition is not documented as synchronous, and +# submitting into a half-cancelled submission is exactly the kind of ambiguity +# this guard exists to avoid. +CANCELLED_SUBMISSION_POLL_ATTEMPTS = 10 +CANCELLED_SUBMISSION_POLL_INTERVAL = 3 + platform :mac do # ============================================================================ # Helper Methods @@ -168,10 +183,20 @@ platform :mac do # Note what is deliberately NOT blocking: an editable version carrying an # older version string. Renaming that forward is legitimate, and is how the # recovery from that incident worked. + # + # Nor is a REJECTION blocking, which promote run 32779335838 showed the hard + # way. Apple rejected iOS 1.7.4, but a rejected submission does not close: it + # sits in UNRESOLVED_ISSUES, which the in-progress lookup matches, so the + # version-agnostic check above skipped 1.7.5 and reported 1.7.4 as "already + # in review" while App Store Connect showed it as Rejected. Resubmitting + # after a rejection is precisely what this lane is for, and + # ALREADY_SUBMITTED_STATES already says so; the short-circuit above simply + # never let that decision run. Only an EXPLICIT UNRESOLVED_ISSUES lowers the + # block - an unreadable or unrecognised state keeps the old behaviour. def submission_skip_reason(app_version:, review_in_progress:, - review_version: nil, edit_version: nil, - edit_state: nil) - if review_in_progress + review_version: nil, review_state: nil, + edit_version: nil, edit_state: nil) + if review_in_progress && review_state != REJECTED_REVIEW_STATE held_by = review_version ? "#{review_version} is" : "another version is" return "#{held_by} already in review; skipping submission of " \ "#{app_version}. App Store Connect allows one submission at a " \ @@ -202,7 +227,8 @@ platform :mac do # app_store_connect_api_key action, whose set_spaceship_token defaults to # true, so Spaceship::ConnectAPI.token is already set by the time a lane # calls this. - def submission_blocker(app_version, platform) + def submission_blocker(app_version, platform, + poll_interval: CANCELLED_SUBMISSION_POLL_INTERVAL) app = Spaceship::ConnectAPI::App.find( CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier), ) @@ -220,18 +246,38 @@ platform :mac do includes: "appStoreVersionForReview", ) - # Short-circuit. An in-progress submission is already a block, so the - # editable version cannot change the answer. Skipping the second lookup is - # partly about latency, but mostly about failure surface: this method - # deliberately lets App Store Connect errors propagate, so a call made - # after the answer is known could fail the lane at the moment we already - # knew the safe outcome. Blocking beats erroring. + # Short-circuit. A submission Apple is actually working on is already a + # block, so the editable version cannot change the answer. Skipping the + # second lookup is partly about latency, but mostly about failure surface: + # this method deliberately lets App Store Connect errors propagate, so a + # call made after the answer is known could fail the lane at the moment we + # already knew the safe outcome. Blocking beats erroring. unless submission.nil? - return submission_skip_reason( + reason = submission_skip_reason( app_version: app_version, review_in_progress: true, + review_state: review_submission_state(submission), review_version: review_submission_version(submission), ) + return reason unless reason.nil? + + # Only a rejection gets past that, and a rejected submission has to be + # retired before anything else can be submitted: deliver runs the same + # in-progress lookup in create_review_submission and hard-errors on it, + # but only AFTER it has renamed the editable version and uploaded + # metadata. Clearing it here keeps that half-finished state from ever + # existing. Nothing is withdrawn from Apple by this: the review is over, + # which is what UNRESOLVED_ISSUES means. + cleared = clear_rejected_submission( + app, mapped, submission, poll_interval: poll_interval + ) + + unless cleared + return "the rejected review submission was cancelled but App Store " \ + "Connect still reports it in progress; skipping submission of " \ + "#{app_version}. Re-run once it clears, or remove it from " \ + "review in App Store Connect." + end end version = app.get_edit_app_store_version(platform: mapped) @@ -258,6 +304,47 @@ platform :mac do nil end + # The state of a review submission, or nil when it cannot be read. + # + # Read defensively for the same reason as the version above, but note the + # consequence is inverted: an unknown version only costs a vaguer message, + # while an unknown state decides whether CI may cancel a submission. nil is + # therefore the SAFE answer here, because only an explicit + # REJECTED_REVIEW_STATE lowers the block or triggers a cancel. + def review_submission_state(submission) + return nil if submission.nil? + + submission.state + rescue StandardError + nil + end + + # Cancels a rejected review submission and waits for App Store Connect to + # stop reporting it. True when the road is clear. + # + # A cancel that raises is deliberately NOT rescued, matching the rest of this + # guard: an unexpected App Store Connect error must stop the lane, not be + # read as permission to carry on. + def clear_rejected_submission(app, mapped, submission, + poll_interval: CANCELLED_SUBMISSION_POLL_INTERVAL) + held = review_submission_version(submission) + UI.important( + "#{held || 'A previous version'} was rejected and its review submission " \ + "is still open; cancelling it so this version can be submitted." + ) + + submission.cancel_submission + + CANCELLED_SUBMISSION_POLL_ATTEMPTS.times do |attempt| + return true if app.get_in_progress_review_submission(platform: mapped).nil? + + last = attempt == CANCELLED_SUBMISSION_POLL_ATTEMPTS - 1 + sleep(poll_interval) if poll_interval.positive? && !last + end + + false + end + def load_api_key if ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"] && ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] && @@ -654,7 +741,10 @@ platform :mac do # Makes the lane a no-op once Apple has this version, or any other version, # so a re-dispatched promotion does not fail on the leg that already - # succeeded and cannot rename a submission that is already under review. + # succeeded and cannot rename a submission that is already under review. A + # REJECTED version is not "Apple has it": rejection leaves the submission + # open in UNRESOLVED_ISSUES, and the blocker retires that so this run can + # resubmit, which is what the lane is for. if (reason = submission_blocker(app_version, "osx")) UI.important("osx: #{reason}") next diff --git a/scripts/release/fastlane_submit_guard_test.rb b/scripts/release/fastlane_submit_guard_test.rb index 126248e3d6..7b5eef0f87 100644 --- a/scripts/release/fastlane_submit_guard_test.rb +++ b/scripts/release/fastlane_submit_guard_test.rb @@ -92,28 +92,69 @@ def self.find(_identifier) end FakeVersion = Struct.new(:version_string, :app_version_state, :app_store_state) -FakeSubmission = Struct.new(:app_store_version_for_review) + +# A review submission as the guard sees it: a state, the version it covers, and +# a cancel that the guard is allowed to call on exactly one of those states. +class FakeSubmission + attr_reader :cancel_calls + + def initialize(state: 'IN_REVIEW', version: nil) + @state = state + @version = version + @cancel_calls = 0 + end + + def state + @state + end + + def app_store_version_for_review + @version + end + + def cancel_submission + @cancel_calls += 1 + self + end +end # Stands in for a submission fetched without the relationship included, which # is what makes the version unreadable in the first place. -class UnreadableSubmission +class UnreadableSubmission < FakeSubmission def app_store_version_for_review raise StandardError, 'relationship not included' end end +# A submission whose STATE cannot be read. Deliberately distinct from the +# above: an unreadable version only costs a vaguer message, while an unreadable +# state is the difference between "Apple is holding this build" and "Apple +# already rejected it", so it must fail closed and must never be cancelled. +class UnreadableStateSubmission < FakeSubmission + def state + raise StandardError, 'state not returned' + end +end + class FakeApp - attr_reader :review_calls, :edit_calls + attr_reader :review_calls, :edit_calls, :submission - def initialize(submission: nil, edit_version: nil) + # clears_after_cancel models App Store Connect retiring a cancelled + # submission, which is what lets the guard go on to submit. false models it + # lingering, which must not be mistaken for a clear road. + def initialize(submission: nil, edit_version: nil, clears_after_cancel: true) @submission = submission @edit_version = edit_version + @clears_after_cancel = clears_after_cancel @review_calls = [] @edit_calls = [] end def get_in_progress_review_submission(platform:, includes: nil) @review_calls << { platform: platform, includes: includes } + return nil if @submission.nil? + return nil if @clears_after_cancel && @submission.cancel_calls.positive? + @submission end @@ -210,6 +251,67 @@ def assert_guard_behaviour(label) "#{label}: the live version state #{live} was treated as blocking " \ "(got #{reason.inspect}); that would stop every future release") end + + # 8. The rejection this guard used to trap, from promote run 32779335838. + # Apple reviewed 1.7.4 and rejected it, but the REVIEW SUBMISSION stays + # open in UNRESOLVED_ISSUES, so the version-agnostic check above read it + # as "a review is in progress" and skipped 1.7.5. Nobody is holding the + # build; a dead submission is. Resubmitting after a rejection is this + # lane's whole job, so it must proceed. + reason = submission_skip_reason( + app_version: '1.7.5', + review_in_progress: true, + review_state: 'UNRESOLVED_ISSUES', + review_version: '1.7.4', + edit_version: '1.7.4', + edit_state: 'REJECTED', + ) + check(reason.nil?, + "#{label}: a rejected (UNRESOLVED_ISSUES) submission blocked the " \ + "resubmission it exists to allow (got #{reason.inspect})") + + # 9. The states where Apple genuinely holds the build still block. This is + # the protection from run 31903095751 and it must survive case 8. + %w[WAITING_FOR_REVIEW IN_REVIEW].each do |held| + reason = submission_skip_reason( + app_version: '1.7.5', + review_in_progress: true, + review_state: held, + review_version: '1.7.4', + ) + check(!reason.nil?, + "#{label}: a submission in #{held} stopped blocking; that is the " \ + 'exact hole that renamed an in-review version') + end + + # 10. Fail CLOSED on a state we cannot read or do not recognise. Only an + # explicit UNRESOLVED_ISSUES may lower the block: if Apple adds a state + # or the field goes unread, the safe answer is the old behaviour. + [nil, '', 'SOME_NEW_APPLE_STATE'].each do |unknown| + reason = submission_skip_reason( + app_version: '1.7.5', + review_in_progress: true, + review_state: unknown, + review_version: '1.7.4', + ) + check(!reason.nil?, + "#{label}: an unrecognised review state #{unknown.inspect} did not " \ + 'block; unknown must never weaken the guard') + end + + # 11. A rejection lowers the FIRST check only. If the editable version is + # somehow already handed over, the second check still stops the lane. + reason = submission_skip_reason( + app_version: '1.7.5', + review_in_progress: true, + review_state: 'UNRESOLVED_ISSUES', + review_version: '1.7.4', + edit_version: '1.7.5', + edit_state: 'WAITING_FOR_REVIEW', + ) + check(!reason.nil?, + "#{label}: UNRESOLVED_ISSUES let a version that is already " \ + 'WAITING_FOR_REVIEW be submitted again') end # --- The App Store Connect wrapper ------------------------------------------ @@ -223,7 +325,10 @@ def assert_wrapper_behaviour(label, platform_arg) # A review under way for a different version blocks, and the version it # covers is read through the relationship the request asked for. $stub_app = FakeApp.new( - submission: FakeSubmission.new(FakeVersion.new('1.7.3', 'WAITING_FOR_REVIEW', nil)), + submission: FakeSubmission.new( + state: 'WAITING_FOR_REVIEW', + version: FakeVersion.new('1.7.3', 'WAITING_FOR_REVIEW', nil), + ), edit_version: FakeVersion.new('1.7.3', 'WAITING_FOR_REVIEW', nil), ) reason = submission_blocker('1.7.4', platform_arg) @@ -249,7 +354,7 @@ def assert_wrapper_behaviour(label, platform_arg) 'already in progress; that is an avoidable way to fail the lane') # An unreadable relationship must not weaken the block. - $stub_app = FakeApp.new(submission: UnreadableSubmission.new) + $stub_app = FakeApp.new(submission: UnreadableSubmission.new(state: 'IN_REVIEW')) reason = submission_blocker('1.7.4', platform_arg) check(!reason.nil?, "#{label}: a submission whose version could not be read stopped blocking") @@ -283,6 +388,73 @@ def assert_wrapper_behaviour(label, platform_arg) $stub_app = nil check(submission_blocker('1.7.4', platform_arg).nil?, "#{label}: a missing app record did not fall through") + + # A rejection left open: the dead submission is cancelled and the lane goes + # on to read the editable version, which is REJECTED and therefore + # submittable. Both halves matter - cancelling without proceeding leaves the + # release stuck, and proceeding without cancelling hits deliver's own + # in-progress check (deliver/submit_for_review.rb) AFTER it has renamed the + # version and uploaded metadata. + rejected = FakeSubmission.new( + state: 'UNRESOLVED_ISSUES', + version: FakeVersion.new('1.7.4', 'REJECTED', nil), + ) + $stub_app = FakeApp.new( + submission: rejected, + edit_version: FakeVersion.new('1.7.4', 'REJECTED', nil), + ) + reason = submission_blocker('1.7.5', platform_arg) + check(reason.nil?, + "#{label}: a rejected submission still blocked (got #{reason.inspect})") + check(rejected.cancel_calls == 1, + "#{label}: expected the rejected submission to be cancelled exactly " \ + "once, got #{rejected.cancel_calls} cancels") + check($stub_app.edit_calls.length == 1, + "#{label}: the editable version was not consulted after the cancel; " \ + 'the second check is what allows the rename-forward') + + # The cancel is confined to rejections. A build actually with Apple must + # never be withdrawn by CI. + %w[WAITING_FOR_REVIEW IN_REVIEW].each do |held| + live = FakeSubmission.new( + state: held, + version: FakeVersion.new('1.7.4', held, nil), + ) + $stub_app = FakeApp.new(submission: live) + check(!submission_blocker('1.7.5', platform_arg).nil?, + "#{label}: a submission in #{held} did not block the wrapper") + check(live.cancel_calls.zero?, + "#{label}: CI cancelled a live #{held} submission; that pulls a " \ + 'build out of Apple review') + end + + # An unreadable state fails closed AND keeps its hands off the submission. + opaque = UnreadableStateSubmission.new(state: 'UNRESOLVED_ISSUES') + $stub_app = FakeApp.new(submission: opaque) + check(!submission_blocker('1.7.5', platform_arg).nil?, + "#{label}: a submission whose state could not be read stopped blocking") + check(opaque.cancel_calls.zero?, + "#{label}: a submission whose state could not be read was cancelled") + + # A cancel that does not take effect must block rather than fall through. + # poll_interval 0 keeps the retry loop instant here. + stuck = FakeSubmission.new( + state: 'UNRESOLVED_ISSUES', + version: FakeVersion.new('1.7.4', 'REJECTED', nil), + ) + $stub_app = FakeApp.new( + submission: stuck, + edit_version: FakeVersion.new('1.7.4', 'REJECTED', nil), + clears_after_cancel: false, + ) + reason = submission_blocker('1.7.5', platform_arg, poll_interval: 0) + check(!reason.nil?, + "#{label}: a cancel that never took effect was treated as a clear road") + check(stuck.cancel_calls == 1, + "#{label}: expected a single cancel attempt, got #{stuck.cancel_calls}") + check($stub_app.edit_calls.empty?, + "#{label}: the lane carried on to the editable version after the " \ + 'cancel failed to clear the submission') end # --- Both platform Fastfiles ------------------------------------------------ From ad1cf34f2f3d8a0f3fab62d462bf9c4fbe7f0d8e Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 18:02:28 -0400 Subject: [PATCH 010/122] fix(media): guard the library prune schedule and correct the toolbar budget Review round on PR #1251. Schedule the pruning callback only while selection mode is active. The dive media section schedules it unconditionally and this branch copied that, but the two surfaces are not comparable: pruneTo builds a lookup over the whole visible set, and the library pages through thousands of rows where a dive holds a handful. The guard cannot skip a prune that mattered, because state.entries comes from a watched provider (so every change to it runs this build) and entering the mode starts from an empty checked set. Selection changes never reach this build at all; the ValueListenableBuilder owns those. Proven still live by inverting the guard and watching the contract test's prune step fail 3 against an expected 1. Correct the toolbar budget comment, which said "a fourth control does not fit" while grid mode was already showing four controls that do. It meant a fourth ICON BUTTON. Measured the real figures rather than restating the estimate: the row requires exactly 312dp (fits at 312, overflows by 4 at 308), so the slack at 320dp is 8dp, compact density reclaims exactly 8dp per button (3 default-density buttons need 336), and a fourth compact button would overflow by 32dp. --- .../presentation/pages/media_library_view.dart | 17 ++++++++++++++--- .../widgets/media_library_toolbar.dart | 12 +++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/features/media/presentation/pages/media_library_view.dart b/lib/features/media/presentation/pages/media_library_view.dart index 0d2f1b8730..d128a5d8be 100644 --- a/lib/features/media/presentation/pages/media_library_view.dart +++ b/lib/features/media/presentation/pages/media_library_view.dart @@ -71,9 +71,20 @@ class _MediaLibraryViewState extends ConsumerState { final visibleIds = state.entries.map((e) => e.item.id).toList(); // Drop checked ids that a filter or sort change pushed off screen, so a // bulk action can never reach a row the user cannot see. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _selection.pruneTo(visibleIds); - }); + // + // Guarded rather than scheduled unconditionally, unlike the dive media + // section: `pruneTo` walks the whole visible set to build its lookup, and + // this library pages through thousands of rows where a dive holds a + // handful. The guard cannot skip a prune that mattered -- `state.entries` + // comes from a watched provider, so every change to it runs this build, + // and entering the mode starts from an empty checked set with nothing + // stale to drop. Selection changes alone do not reach here at all; the + // ValueListenableBuilder below owns those. + if (_selection.value.isActive) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _selection.pruneTo(visibleIds); + }); + } return SelectableListScope( controller: _selection, diff --git a/lib/features/media/presentation/widgets/media_library_toolbar.dart b/lib/features/media/presentation/widgets/media_library_toolbar.dart index adadab087c..ded118d22f 100644 --- a/lib/features/media/presentation/widgets/media_library_toolbar.dart +++ b/lib/features/media/presentation/widgets/media_library_toolbar.dart @@ -16,11 +16,13 @@ import 'package:submersion/shared/widgets/sort_bottom_sheet.dart'; /// replaced was an Expanded horizontal scroller that claimed all free width /// and squeezed the view-mode selector beside it. /// -/// Fixed widths also mean the row has a hard budget: at 320dp, the narrowest -/// phone the app ships to, three default-density icon buttons plus the -/// view-mode selector overflow by 16px. The icons are compact for that -/// reason, matching the dive media section's header, and a fourth control -/// does not fit without finding space somewhere else. +/// Fixed widths also mean the row has a hard budget, and grid mode already +/// spends nearly all of it: three icon buttons plus the view-mode selector. +/// At 320dp, the narrowest phone the app ships to, those three at default +/// density overflow by 16px, so they are `VisualDensity.compact` (matching +/// the dive media section's header), which reclaims 8dp each and leaves +/// roughly 8dp spare. A FOURTH icon button would overflow by about 32dp; +/// adding one means finding space elsewhere in the row, not just adding it. class MediaLibraryToolbar extends ConsumerWidget { const MediaLibraryToolbar({ super.key, From a50c905a498cc8119d89dd8cb938bec1449da4c3 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 22:17:59 -0400 Subject: [PATCH 011/122] docs: release nots --- docs/releases/v1.7.5.6772.md | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/releases/v1.7.5.6772.md diff --git a/docs/releases/v1.7.5.6772.md b/docs/releases/v1.7.5.6772.md new file mode 100644 index 0000000000..a8f8029b60 --- /dev/null +++ b/docs/releases/v1.7.5.6772.md @@ -0,0 +1,160 @@ +# 🎉 Submersion v1.7.5.6772 Release Notes + +v1.7.5 is another large release, incorporating 102 merged changes since v1.7.4. The headliners are a top-level Media section that holds every photo, video, and document in your logbook, a site map and 3D seascape unified into one pane with contour lines and chart mode, Google Drive as a full sync backend on all five platforms, a real-gas nitrox and trimix blender that costs the fill, raw O2 cell millivolts drawn on CCR profiles, and support for dive-computer USB cables that macOS and Android refuse to claim. This release upgrades the database (schema 150 to 160), so please read the upgrade notes at the end. + +--- + +## ✨ New and improved + +### A Media section for your whole logbook + +Media becomes a top-level destination alongside Dives, Sites, Gear, and Statistics. Inside it is a console with four areas. **Library** is every photo, video, and document you have logged, paged and viewable as a grid, grouped by dive, or as a timeline, with multi-select for delete and share and a full-screen viewer that steps to the previous and next item. **Sources** lists where your media actually lives, including watched folders that scan for files you add later and saved smart albums, and it can check every item's availability in one pass. **Transfers** shows uploads and downloads in flight. **Import** brings files in. + +Two things make the library trustworthy rather than just large. Every item now carries its provenance, so an info panel can tell you which source served the thumbnail you are looking at and whether it came from a cache, the network, or local disk, and a status badge on the tile says so at a glance. And when a file moves, a repair wizard searches your folders, photo library, and cloud stores for it, matching on content hash rather than filename, so a whole reorganized photo folder can be reconnected in one pass. A per-device repair log records what it changed. + +Every media row is attached to a dive or a site from the moment it is inserted. Importing opens a review page first: confident timestamp matches arrive pre-checked, and anything ambiguous asks you to pick a dive or site before it is written. Missing files are a filter chip in the Library rather than a separate section, with the repair tools in a banner above the grid. + +### Contour lines, chart mode, and real imagery on the site maps + +The 2D site map and the 3D seascape are no longer separate screens. They are one pane that morphs between the two, on the site detail page, on the standalone sites map, and from the dive detail header, and a fullscreen tap opens straight into 3D. + +The terrain itself gained a great deal. Isobaths are extracted with marching squares and drawn as labelled contour ribbons at unit-aware intervals, with a depth legend and a chart mode camera that looks at the site the way a paper chart does. Steep walls get their own highlight mesh. Map imagery can be draped onto the 3D terrain as a textured mosaic, and the same depth data can be drawn as an overlay on the flat 2D map. All of it is controlled from an appearance sheet whose settings sync per diver. + +### Place your own markers on a dive site + +Sites gain **features**: diver-placed annotations you tap to place, name, and edit. They render as markers on every 2D map host and in the 3D seascape, and they sync between your devices like any other entity. Mooring blocks, the swim-through you always miss, where the current picks up: whatever you want the site to remember. + +### Google Drive sync on every platform + +Google Drive is now a complete cloud sync backend on iOS, macOS, Android, Windows, and Linux, selectable in Cloud Sync settings next to the existing providers. Desktop signs in through a PKCE loopback flow in your browser rather than an embedded window, and the sign-in survives a cold launch. Google Drive as a media store is now offered only where it can actually work, instead of being listed everywhere and failing on desktop. + +### A gas blender that knows about real gas, temperature, and price + +Gas Calculators gains a sixth tab. Give it what is in the cylinder and what you want, and it returns the fill order and the pressure to top up to at each step, for nitrox with a two-gas oxygen balance and for trimix with a three-gas solve across up to three configurable fill gases. When a blend is impossible it says why: target not higher than start, an invalid mix, identical fill gases, no helium source, or a negative amount required. + +The solver conserves molar density rather than a temperature-free normal volume, which is what lets it take two temperatures: the one you fill at and the one the cylinder settles to. You choose the gas model (ideal, Van der Waals, or a Z-factor real gas), each step names the bar that gas delivers rather than a litre count, target mixes can be saved as templates seeded with the common ones, and a per-gas unit price turns the procedure into a running bill for a whole blending session. + +### Raw O2 cell millivolts on the CCR profile + +Rebreather divers can now see what each oxygen cell actually reported. Per-cell millivolt curves are captured on import, stored with the profile, and drawn as one line per cell on the profile chart's right axis, with a legend toggle and tooltip rows. Above them sits a labelled status rug showing cell agreement, so a cell drifting away from its siblings is visible at a glance rather than something you reconstruct afterwards. Contributed by @readme42. + +### Dive-computer cables that macOS and Android never claimed + +Some dive cables carry a reprogrammed FTDI product ID, and the operating system will not bind a driver to it. On macOS that means no serial port is ever created and every download fails immediately with "no serial ports"; on Android there is no kernel driver for the family at all. Submersion now speaks the FTDI wire protocol directly over raw USB on both platforms, including the line-control signals the Oceanic Atom2 family needs before it will talk. Serial ports are still tried first, so nothing that already works changes, and raw USB is only reached once they are exhausted. Linux and Windows already worked and are untouched. + +### Maintenance history you can read, filter, and export + +Service history now names the maintenance task rather than showing a bare row, filters by task, type, and year, and defaults a service's price from its kind and schedule so you are not retyping the same figure. It exports to Excel as its own Maintenance Log sheet. Alongside it, service types are unified: one name, one picker, one Settings entry to manage them, and each type can carry a default category. + +### Travel gas and lost-gas contingency planning + +Any carried cylinder can be flagged as travel gas, independent of its role, so a stage, diluent, or pony bottle breathed on the descent is planned as one. Lost-gas contingencies became interactive: every deviation and lost-cylinder row is individually collapsible and tappable, and selecting one now updates the headline Runtime, TTS, NDL, Deco, and CNS figures as well as ghosting the chart, with a "Previewing" chip making clear you are looking at a what-if. Contributed by @dotanalon. + +### Ratio Computers XML import + +Submersion now detects and parses the XML files Ratio computers produce (iX3M, iDive, and family), which became the only route in after a firmware update libdivecomputer does not yet recognise. Header metadata, full profiles, gas switches, gradient factors, and the computer model and serial from the filename all come across. Contributed by @zorcik. + +### Smaller additions + +- Dive sites carry an **entry and exit method**, editable on the site, suggested from the dives you have already logged there, and snapped into a new dive when you assign the site. +- Editing a dive's tags now offers the tags you have used before instead of asking you to remember them. +- The bulk dive editor can set **buddy roles and your own role**, and can update tank specifications in place while keeping each dive's recorded pressures. +- **DPVs are an equipment type**, with their own speed and burn-time fields, and MacDive imports recognise scooters. Contributed by @etlami. +- A **dive mode badge** appears across the dive detail header and the dive lists. Contributed by @readme42. +- A **"no buddy assigned" filter** for dives. Contributed by @alpheios-one. +- SAC rate gains a selectable ideal or real gas model, and a longstanding unit mismatch in its calculation is fixed. +- Planning tools open in a detail pane on desktop rather than pushing a full page, list action toolbars are consistent across every entity, and the home screen's recent-media ribbon widened. +- Startup failures are now classified by what actually failed and when, instead of every launch problem being reported as a failed database upgrade. +- Every snackbar has a close button. +- A large localization sweep covering the screens reported in #1042, fixed at the source rather than patched per screen, across all eleven languages. +- Numeric fields are read and seeded in your own locale, so a comma decimal separator works where you expect it to. +- Local builds on Fedora. Contributed by @dotanalon. + +--- + +## 🐞 Bug fixes + +**Statistics and analysis** + +- **Statistics reported 0 deco dives** for divers whose dive pages clearly showed DECO badges, ceilings, and stop schedules. The statistic counted only computer-reported ceiling samples, which several import sources never write; it now uses the app's own analysis. Your deco percentage will change after this update. +- **Time at depth was derived from sample counts rather than timestamps**, so it was wrong for any profile that does not sample at a fixed interval. +- **Trip statistics showed bottom time under a "total runtime" label.** +- Gradient factors that came from your settings were labelled as though the computer had reported them. + +**Dive computers, imports, and re-parsing** + +- **Re-parsing a dive could destroy a combined dive's profile**, and separately failed to refresh the water temperature, maximum CNS, and entry and exit times, leaving stale values behind. Re-parsing also no longer breaks the alignment of a consolidated multi-computer strand. +- Hardware and software flow control were inverted in the serial backends. +- Download candidates were logged as serial transport even when they were not. +- Dives were filtered by serial number instead of computer id, so two computers sharing a serial were confused. +- Profile samples were promoted by computer id rather than by the source that owns them, which could move a sample belonging to another source. +- **Subsurface imports duplicated dive sites and lost their coordinates.** +- MacDive XML imports silently brought in no certifications. The import now says why, and scopes the notice to the file rather than the whole import. + +**Media** + +- **Viewing media attached to a dive froze the entire app for 5 to 30 seconds.** Media writes were re-running the full decompression analysis chain, and the enrichment backfill was ticking once per row instead of once per batch. +- **The Media section stalled the UI isolate** while it built listings, and sent the whole index back even when a listing was cut short. +- **The gallery hung on Windows and crashed on Android.** +- A slow read could mark a photo as missing when it was only slow, and a denied gallery permission could orphan a row that was perfectly fine. Only a positive finding of absence marks anything missing now, and each fetch has a slot budget so one stalled item cannot block the rest. +- A video tile in a cloud store downloaded the entire video to draw its thumbnail. +- The dive detail page's "Unlink" was a hard delete. Unlink and delete are now separate actions with honest wording. +- The site page's Unlink deleted photos that a dive still referenced. +- Media picked on platforms that hand back a handle rather than a path could not be added to a site. + +**Sync, backup, and startup** + +- **A locked database at launch failed the launch outright.** The app now waits the lock out and retries. +- **Pre-migration backups were not reliably restorable.** They inherited WAL journal mode, which meant they could not be read back from the read-only directory the file picker hands over on iOS and macOS, and a failed restore could leave the app with no working database at all. +- With database password protection on, Android SAF backups wrote encrypted bytes into an artifact that is portable plaintext by contract. +- **App Store builds stopped receiving changes from direct-download builds during every Apple review window.** Manifests now stamp a compatibility floor rather than the writer's schema version, and the banner names the devices being held instead of telling a store user to update. +- Restoring a backup from a newer schema is now refused before the swap rather than after it. +- Five tables were missing from the sync timestamp list, so their edits never propagated. +- Headless background tasks opened the default database instead of the active diver's. +- Mobile devices showed up in sync with an unhelpful name, and the screen could sleep partway through sync maintenance. +- Google Drive silent authentication did not survive a cold launch. + +**Planner, gear, and courses** + +- **Lead carried as weights-type equipment was counted twice** in the buoyancy planner. +- Selecting a contingency ghosted the chart but left the headline statistics showing the base plan. +- DPV speed and burn time were read in the wrong units. +- Manual checklist-to-dive linking had gone missing. +- The course editor's header Save did not persist edits, and a linked training course did not appear when a dive was viewed read-only. +- A saved profile trim did not stick, because the superseded original was left in place. + +**Maps, 3D, and interface** + +- Pinch zoom rotated the map. +- The seascape map frame rendered mirrored. +- Contour lines were buried by rough terrain, and the compass rose drifted out of its corner under pan. +- The filter sheet's header and actions scrolled away with the content. +- Advanced Search jumped off whichever tab opened it. +- Equipment icons drew as generic glyphs instead of dive gear. +- Nearby species listed land animals and plants. +- The setup wizard gave no reason when iCloud was unavailable. +- Windows builds shipped without several native components that a configure-time guard was silently dropping. +- The iOS permission primer wording was reworked for App Review guideline 5.1.1(iv), and notification permission is re-read after a trip to Settings. + +--- + +## ⚠️ Upgrade notes + +- **The database upgrades from schema 150 to 160.** The migration runs once on first launch. As always, take a backup before updating, and update your other devices reasonably soon so they are not held back. +- **Media not attached to a dive or a site is removed from the library on first launch.** Every media row now carries a dive or site link from the moment it is created, and a sweep on each launch clears rows that predate that rule. **Your original files are never touched.** If you kept photos in the library without linking them to anything, import them again and pick a dive or site in the review step. +- **Unlinking a photo from a dive now removes it from the library too.** Previously it left an unattached row behind. Use Delete when you want the file gone, and move-to-dive when you want to keep it. +- **The main database now runs in WAL journal mode.** Backups are exported through SQLite rather than byte-copied, so an artifact is a consistent, compacted point in time even while the app is writing. Backups made by earlier versions still restore normally. +- **Several statistics will change their numbers.** Deco obligation is now computed from the app's own analysis, time at depth from timestamps, and trip runtime is genuinely runtime. Those are corrections, not regressions, but the figures will not match what v1.7.4 showed. +- **Cross-version sync is better, not worse.** Devices still on v1.7.2 from the App Store will start accepting changes from newer devices again without needing an update themselves. +- **No minimum operating system change.** macOS 12, iOS 15, Android 8.0, Windows and Linux are all unchanged from v1.7.4. Android builds now compile against API 37, which needs nothing from you. +- **Google Drive appears in Cloud Sync settings on all five platforms.** On Windows and Linux, signing in opens your browser and waits for the callback; the dialog can be cancelled if you change your mind. + +--- + +## 🙏 Contributors + +Thank you to everyone whose work is in this release. @readme42 drew raw O2 cell millivolts and the cell agreement rug on the CCR profile, and added the dive mode badge. @etlami built the real-gas nitrox and trimix blender and added DPVs as an equipment type. @dotanalon added travel gas and interactive lost-gas contingency planning, and got local builds working on Fedora. @zorcik added Ratio Computers XML import. @alpheios-one added the "no buddy assigned" dive filter. And @ericgriffin. + +A warm welcome to @etlami, @dotanalon, @zorcik, and @alpheios-one, all first-time contributors. + +--- From e9d425cefb6fcf2a409cb79dbcd022ca683709a4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 23:31:35 -0400 Subject: [PATCH 012/122] fix(android): give sideloaded APKs an update path (#1258) `UpdateChannelConfig.isAutoUpdateEnabled` returned false on Android on the grounds that Android is a store-only platform, but Submersion is not on Play. Sideloaded APK installs therefore had no banner, no check button and no version comparison, so a diver stayed on whatever build they first installed. That is how the launch failure fixed in 1.7.5.6772 could not reach the phone reported in #1256: the fix shipped, but nothing on the device could see it. The guard conflated two questions: can this platform self-install a binary, and does a store already deliver updates here. iOS answers yes to the second and keeps its guard. Android answers no to both, exactly like Linux, and now follows the compile-time UPDATE_CHANNEL like every other platform. No build change is needed: build-all.yml already builds the APK with UPDATE_CHANNEL=github and the Play bundle with UPDATE_CHANNEL=playstore, so the switch-over when Play lands is a build flag rather than another code change. This is also what the original design called for; the table in docs/plans/2026-02-14-auto-update-design.md has always listed the Android APK as GitHub-updated. Nothing else was needed to make it work. The Android branch of the GitHub updater was already written and unreachable, every release already publishes a matching Android.apk asset, and GithubUpdateService installs nothing: the banner's Download action opens the APK URL externally, which is the same flow a diver used to install the app in the first place. So there is no self-install machinery and no REQUEST_INSTALL_PACKAGES permission here. The beta channel comes along with it, deliberately. beta.yml publishes the Android APK to beta-builds alongside the desktop artifacts and those releases are not marked prerelease, so /releases/latest resolves them. The dead "Join the Beta" tile is fixed by the same split. It renders only when auto-update is off, so the sideloaded APK no longer offers the Play opt-in page for an app that is not listed on Play. Its platform mapping moves out of the settings page into betaEnrollUrlFor(), next to the constants, so that condition is covered by a test rather than living inside a private getter on a 3,000-line page. Both new rules are pure functions taking the platform as an argument, because Platform.isIOS is a host fact under flutter test and a getter reading it can only ever exercise one branch. --- .../domain/beta_program_links.dart | 37 ++++++++++ .../domain/entities/update_channel.dart | 32 +++++++-- .../presentation/pages/settings_page.dart | 15 ++-- .../services/github_update_service_test.dart | 54 +++++++++++++++ .../domain/beta_program_links_test.dart | 69 +++++++++++++++++++ .../domain/entities/update_channel_test.dart | 59 ++++++++++++++++ 6 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 test/features/auto_update/domain/beta_program_links_test.dart diff --git a/lib/features/auto_update/domain/beta_program_links.dart b/lib/features/auto_update/domain/beta_program_links.dart index 66f0ffd924..6397abe543 100644 --- a/lib/features/auto_update/domain/beta_program_links.dart +++ b/lib/features/auto_update/domain/beta_program_links.dart @@ -1,5 +1,42 @@ +import 'dart:io' show Platform; + +import 'package:submersion/features/auto_update/domain/entities/update_channel.dart'; + /// Public enrollment links for the beta program. An empty link hides the /// corresponding Join-the-Beta tile. /// const kTestFlightBetaUrl = 'https://testflight.apple.com/join/aMD393sB'; const kPlayBetaOptInUrl = 'https://play.google.com/apps/testing/app.submersion'; + +/// The store beta-enrollment link to offer on this build, or null when there +/// is none. +String? get betaEnrollUrl => betaEnrollUrlFor( + isIOS: Platform.isIOS, + isMacOS: Platform.isMacOS, + isAndroid: Platform.isAndroid, + autoUpdateEnabled: UpdateChannelConfig.isAutoUpdateEnabled, +); + +/// The rule behind [betaEnrollUrl], with the host platform and the updater +/// state passed in rather than read from the environment. +/// +/// Only store builds are offered a link. A build with its own updater picks +/// its channel in Settings > Updates > Update channel, so pointing it at a +/// store testing program would be a dead end: that is what the sideloaded +/// Android APK did, offering the Play opt-in page for an app that is not +/// listed on Play (#1258). +String? betaEnrollUrlFor({ + required bool isIOS, + required bool isMacOS, + required bool isAndroid, + required bool autoUpdateEnabled, +}) { + if (autoUpdateEnabled) return null; + if (isIOS || isMacOS) return _orNull(kTestFlightBetaUrl); + if (isAndroid) return _orNull(kPlayBetaOptInUrl); + return null; +} + +/// An empty constant means the program is not live yet, which reads as "no +/// link" rather than as a link to nowhere. +String? _orNull(String url) => url.isEmpty ? null : url; diff --git a/lib/features/auto_update/domain/entities/update_channel.dart b/lib/features/auto_update/domain/entities/update_channel.dart index cee97b5e21..d89f088034 100644 --- a/lib/features/auto_update/domain/entities/update_channel.dart +++ b/lib/features/auto_update/domain/entities/update_channel.dart @@ -25,12 +25,32 @@ class UpdateChannelConfig { return UpdateChannel.github; } - /// Whether in-app auto-update is enabled. - /// Always false on iOS and Android (store-only platforms). - /// Store-distributed builds rely on the store's own update mechanism. - static bool get isAutoUpdateEnabled { - if (Platform.isIOS || Platform.isAndroid) return false; - return !isStoreChannel(current); + /// Whether in-app update checking is enabled. + /// + /// iOS is the one genuinely store-only platform: every iOS build reaches a + /// device through the App Store or TestFlight, both of which deliver + /// updates themselves, so no compile-time channel can turn the updater on + /// there. Every other platform follows [current], which store builds set + /// via the UPDATE_CHANNEL dart-define. + /// + /// Android is deliberately not special-cased (#1258). The sideloaded APK is + /// built with UPDATE_CHANNEL=github and polls GitHub Releases exactly as + /// Linux does; the Play bundle is built with UPDATE_CHANNEL=playstore and + /// turns the updater off. Treating Android as store-only left every APK + /// install with no update path at all while Submersion is not on Play. + static bool get isAutoUpdateEnabled => + isAutoUpdateEnabledFor(isIOS: Platform.isIOS, channel: current); + + /// The rule behind [isAutoUpdateEnabled], with the host platform and the + /// distribution channel passed in rather than read from the environment. + /// [isAutoUpdateEnabled] binds them to the running build; tests use this + /// form to reach combinations the host platform cannot produce. + static bool isAutoUpdateEnabledFor({ + required bool isIOS, + required UpdateChannel channel, + }) { + if (isIOS) return false; + return !isStoreChannel(channel); } /// Returns true for every channel except [UpdateChannel.github]. diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index e293eb1593..0b722cb84f 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -3039,9 +3039,9 @@ class _AboutSectionContentState extends ConsumerState<_AboutSectionContent> { ), // Beta enrollment signpost for store builds: the app cannot // switch channels itself there, so link to the store's beta - // program. Hidden until the enrollment links exist. - if (!UpdateChannelConfig.isAutoUpdateEnabled && - _betaEnrollUrl.isNotEmpty) ...[ + // program. Null on every build that has its own updater, and + // on platforms whose program is not live. + if (betaEnrollUrl case final enrollUrl?) ...[ const Divider(height: 1), ListTile( leading: const Icon(Icons.science_outlined), @@ -3050,7 +3050,7 @@ class _AboutSectionContentState extends ConsumerState<_AboutSectionContent> { context.l10n.settings_updates_joinBetaSubtitle, ), onTap: () => launchUrl( - Uri.parse(_betaEnrollUrl), + Uri.parse(enrollUrl), mode: LaunchMode.externalApplication, ), ), @@ -3204,13 +3204,6 @@ class _AboutSectionContentState extends ConsumerState<_AboutSectionContent> { ); } - /// The store beta-program URL for this platform ('' hides the signpost). - String get _betaEnrollUrl { - if (Platform.isIOS || Platform.isMacOS) return kTestFlightBetaUrl; - if (Platform.isAndroid) return kPlayBetaOptInUrl; - return ''; - } - Future _showChannelPicker(BuildContext context) async { final current = ref.read(releaseChannelProvider); final selected = await showDialog( diff --git a/test/features/auto_update/data/services/github_update_service_test.dart b/test/features/auto_update/data/services/github_update_service_test.dart index ba0584d99c..773088810e 100644 --- a/test/features/auto_update/data/services/github_update_service_test.dart +++ b/test/features/auto_update/data/services/github_update_service_test.dart @@ -137,6 +137,60 @@ void main() { ); }); + // The scenario from #1258: a phone sitting on v1.7.4.6062 while + // v1.7.5.6772 was published. currentVersion is assembled in + // update_providers.dart from PackageInfo, whose Android values are the + // APK's versionName and versionCode. Promoted stable APKs carry the + // tag's build number as their versionCode (promote.yml copies the beta + // artifacts, and beta APKs are built with build-number = commit count), + // so the assembled string is directly comparable to the 4-segment tag. + test( + 'an Android install is offered the APK asset for a newer tag', + () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode(makeRelease(tagName: 'v1.7.5.6772')), + 200, + ); + }); + + final service = GithubUpdateService( + owner: owner, + repo: repo, + currentVersion: '1.7.4.6062', + platformSuffix: 'Android.apk', + httpClient: client, + ); + + final status = await service.checkForUpdate(); + expect(status, isA()); + expect((status as UpdateAvailable).version, '1.7.5.6772'); + expect(status.downloadUrl, endsWith('Android.apk')); + }, + ); + + test('an Android install on the published build stays quiet', () async { + // The other half of the same wiring: if versionCode did not line up + // with the tag's build number, every check would report the version + // the device is already running as an available update, forever. + final client = MockClient((request) async { + return http.Response( + jsonEncode(makeRelease(tagName: 'v1.7.5.6772')), + 200, + ); + }); + + final service = GithubUpdateService( + owner: owner, + repo: repo, + currentVersion: '1.7.5.6772', + platformSuffix: 'Android.apk', + httpClient: client, + ); + + expect(await service.checkForUpdate(), isA()); + }); + test('returns UpToDate when current is newer than remote', () async { final client = MockClient((request) async { return http.Response(jsonEncode(makeRelease(tagName: 'v0.9.0')), 200); diff --git a/test/features/auto_update/domain/beta_program_links_test.dart b/test/features/auto_update/domain/beta_program_links_test.dart new file mode 100644 index 0000000000..927d686df8 --- /dev/null +++ b/test/features/auto_update/domain/beta_program_links_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/auto_update/domain/beta_program_links.dart'; + +void main() { + group('betaEnrollUrlFor', () { + test('offers nothing when the build has its own updater', () { + // A build that polls GitHub Releases switches channels in Settings > + // Updates > Update channel, so sending it to a store testing program + // would be a dead end. This is the sideloaded Android APK (#1258): it + // was offering the Play opt-in page for an app not listed on Play. + expect( + betaEnrollUrlFor( + isIOS: false, + isMacOS: false, + isAndroid: true, + autoUpdateEnabled: true, + ), + isNull, + ); + }); + + test('offers the Play opt-in page to a Play-channel Android build', () { + expect( + betaEnrollUrlFor( + isIOS: false, + isMacOS: false, + isAndroid: true, + autoUpdateEnabled: false, + ), + kPlayBetaOptInUrl, + ); + }); + + test('offers TestFlight to iOS and to Mac App Store builds', () { + expect( + betaEnrollUrlFor( + isIOS: true, + isMacOS: false, + isAndroid: false, + autoUpdateEnabled: false, + ), + kTestFlightBetaUrl, + ); + expect( + betaEnrollUrlFor( + isIOS: false, + isMacOS: true, + isAndroid: false, + autoUpdateEnabled: false, + ), + kTestFlightBetaUrl, + ); + }); + + test('offers nothing on platforms with no store program', () { + // Linux and Windows store builds have no public testing programme to + // enroll in, so there is no link to show even with updates disabled. + expect( + betaEnrollUrlFor( + isIOS: false, + isMacOS: false, + isAndroid: false, + autoUpdateEnabled: false, + ), + isNull, + ); + }); + }); +} diff --git a/test/features/auto_update/domain/entities/update_channel_test.dart b/test/features/auto_update/domain/entities/update_channel_test.dart index 4be765ca05..bcd900d367 100644 --- a/test/features/auto_update/domain/entities/update_channel_test.dart +++ b/test/features/auto_update/domain/entities/update_channel_test.dart @@ -55,5 +55,64 @@ void main() { // Default channel is github, which is not a store channel expect(UpdateChannelConfig.isAutoUpdateEnabled, true); }); + + // The host platform under flutter test is never iOS, so the getter above + // can only ever exercise one branch. These cases drive the pure form + // directly to cover the platform/channel matrix (#1258). + group('isAutoUpdateEnabledFor', () { + test('is false on iOS even on the github channel', () { + // iOS ships only through the App Store and TestFlight, both of which + // deliver updates themselves. No dart-define can turn this on. + expect( + UpdateChannelConfig.isAutoUpdateEnabledFor( + isIOS: true, + channel: UpdateChannel.github, + ), + false, + ); + }); + + test('is true off iOS on the github channel', () { + // This is the Android sideloaded APK, which build-all.yml builds with + // UPDATE_CHANNEL=github, alongside Linux and the direct desktop + // builds. Re-adding a Platform.isAndroid guard here fails this test. + expect( + UpdateChannelConfig.isAutoUpdateEnabledFor( + isIOS: false, + channel: UpdateChannel.github, + ), + true, + ); + }); + + test('is false off iOS on the playstore channel', () { + // The Play bundle is built with UPDATE_CHANNEL=playstore, so the + // switch-over when Play lands is a build flag, not a code change. + expect( + UpdateChannelConfig.isAutoUpdateEnabledFor( + isIOS: false, + channel: UpdateChannel.playstore, + ), + false, + ); + }); + + test('is false off iOS on every other store channel', () { + for (final channel in [ + UpdateChannel.appstore, + UpdateChannel.msstore, + UpdateChannel.snapstore, + ]) { + expect( + UpdateChannelConfig.isAutoUpdateEnabledFor( + isIOS: false, + channel: channel, + ), + false, + reason: '$channel is a store channel', + ); + } + }); + }); }); } From 37b0404defc188dc7f5ad639d4dd51b42f0560b2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 23:34:44 -0400 Subject: [PATCH 013/122] fix: localize depth display in the dive site list (#1257) The Detailed site list rendered depths in meters regardless of the diver's unit setting, while the Table view rendered them correctly. SiteListTile._depthString was a getter on the widget class rather than the State, so it had no access to ref and hardcoded the "m" suffix with no conversion. The Table view was already correct because it formats through SiteFieldDescriptor.formatValue, which receives a UnitFormatter. Moving the getter into the State fixes both the Detailed view and the site search delegate, which reuses the same tile. The active depth-filter chip on the same screen had the same defect, and fixing it surfaced a second, silent bug: the filter sheet's depth inputs were suffixed "m" and fed straight into a comparison against the meter-valued site depths. An imperial diver typing 100 was filtering at 100 meters, not feet. The bounds now stay in meters internally, matching the values they are compared against, and convert only at the input and display edges. The three depthRangeXxx ARB strings carried a hardcoded unit token, which is now dropped in every locale so the symbol comes from the formatter. Localized lead-ins ("Up to", "Bis zu", "Fino a") are preserved. Arabic and Hebrew previously rendered their own meter abbreviations in these chips and now use m/ft, matching every other depth display in the app. --- .../widgets/site_filter_sheet.dart | 32 +++- .../widgets/site_list_content.dart | 49 ++++-- lib/l10n/arb/app_ar.arb | 6 +- lib/l10n/arb/app_de.arb | 6 +- lib/l10n/arb/app_en.arb | 9 +- lib/l10n/arb/app_es.arb | 6 +- lib/l10n/arb/app_fr.arb | 6 +- lib/l10n/arb/app_he.arb | 6 +- lib/l10n/arb/app_hu.arb | 6 +- lib/l10n/arb/app_it.arb | 6 +- lib/l10n/arb/app_localizations.dart | 12 +- lib/l10n/arb/app_localizations_ar.dart | 6 +- lib/l10n/arb/app_localizations_de.dart | 6 +- lib/l10n/arb/app_localizations_en.dart | 6 +- lib/l10n/arb/app_localizations_es.dart | 6 +- lib/l10n/arb/app_localizations_fr.dart | 6 +- lib/l10n/arb/app_localizations_he.dart | 6 +- lib/l10n/arb/app_localizations_hu.dart | 6 +- lib/l10n/arb/app_localizations_it.dart | 6 +- lib/l10n/arb/app_localizations_nl.dart | 6 +- lib/l10n/arb/app_localizations_pt.dart | 6 +- lib/l10n/arb/app_localizations_zh.dart | 6 +- lib/l10n/arb/app_nl.arb | 6 +- lib/l10n/arb/app_pt.arb | 6 +- lib/l10n/arb/app_zh.arb | 6 +- .../widgets/site_filter_sheet_test.dart | 160 ++++++++++++++++++ .../widgets/site_list_content_test.dart | 147 +++++++++++++++- 27 files changed, 439 insertions(+), 96 deletions(-) create mode 100644 test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart diff --git a/lib/features/dive_sites/presentation/widgets/site_filter_sheet.dart b/lib/features/dive_sites/presentation/widgets/site_filter_sheet.dart index 61dc215367..9fd0a3f23f 100644 --- a/lib/features/dive_sites/presentation/widgets/site_filter_sheet.dart +++ b/lib/features/dive_sites/presentation/widgets/site_filter_sheet.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/number_input.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; /// Bottom sheet for filtering dive sites. /// @@ -51,11 +53,14 @@ class _SiteFilterSheetState extends ConsumerState { _countryController = TextEditingController(text: _country ?? ''); _regionController = TextEditingController(text: _region ?? ''); + // Depth bounds are held in meters, matching the stored site depths they + // are compared against, but the diver reads and edits them in their unit. + final units = UnitFormatter(widget.ref.read(settingsProvider)); _minDepthController = TextEditingController( - text: _minDepth?.toStringAsFixed(0) ?? '', + text: _depthInputText(units, _minDepth), ); _maxDepthController = TextEditingController( - text: _maxDepth?.toStringAsFixed(0) ?? '', + text: _depthInputText(units, _maxDepth), ); } @@ -257,7 +262,22 @@ class _SiteFilterSheetState extends ConsumerState { ); } + /// Render a meters-valued bound as whole units of the diver's depth unit. + String _depthInputText(UnitFormatter units, double? meters) { + if (meters == null) return ''; + return formatDecimalForInput(units.convertDepth(meters).roundToDouble()); + } + + /// Convert a depth the diver typed in their own unit back to the meters the + /// filter compares against. + double? _depthInputToMeters(String value) { + final typed = parseUserDecimal(value); + if (typed == null) return null; + return UnitFormatter(ref.read(settingsProvider)).depthToMeters(typed); + } + Widget _buildDepthSection() { + final depthSymbol = UnitFormatter(ref.watch(settingsProvider)).depthSymbol; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -274,12 +294,12 @@ class _SiteFilterSheetState extends ConsumerState { keyboardType: TextInputType.number, decoration: InputDecoration( labelText: context.l10n.diveSites_filter_depth_min_label, - suffixText: 'm', + suffixText: depthSymbol, border: const OutlineInputBorder(), ), onChanged: (value) { setState(() { - _minDepth = parseUserDecimal(value); + _minDepth = _depthInputToMeters(value); }); }, ), @@ -294,12 +314,12 @@ class _SiteFilterSheetState extends ConsumerState { keyboardType: TextInputType.number, decoration: InputDecoration( labelText: context.l10n.diveSites_filter_depth_max_label, - suffixText: 'm', + suffixText: depthSymbol, border: const OutlineInputBorder(), ), onChanged: (value) { setState(() { - _maxDepth = parseUserDecimal(value); + _maxDepth = _depthInputToMeters(value); }); }, ), diff --git a/lib/features/dive_sites/presentation/widgets/site_list_content.dart b/lib/features/dive_sites/presentation/widgets/site_list_content.dart index 5d4e90c284..07b9a7f199 100644 --- a/lib/features/dive_sites/presentation/widgets/site_list_content.dart +++ b/lib/features/dive_sites/presentation/widgets/site_list_content.dart @@ -1104,19 +1104,25 @@ class _SiteListContentState extends ConsumerState { ); } + /// Chip label for the active depth filter. + /// + /// The bounds are held in meters, like every other stored depth, so they are + /// converted for display. A two-ended range carries a single trailing symbol, + /// so only the upper bound is formatted with one. String _formatDepthRange(double? min, double? max) { + final units = UnitFormatter(ref.watch(settingsProvider)); if (min != null && max != null) { return context.l10n.diveSites_list_activeFilter_depthRangeBoth( - min.toInt(), - max.toInt(), + units.convertDepth(min).toStringAsFixed(0), + units.formatDepth(max, decimals: 0), ); } else if (min != null) { return context.l10n.diveSites_list_activeFilter_depthRangeMin( - min.toInt(), + units.formatDepth(min, decimals: 0), ); } else if (max != null) { return context.l10n.diveSites_list_activeFilter_depthRangeMax( - max.toInt(), + units.formatDepth(max, decimals: 0), ); } return ''; @@ -1303,16 +1309,6 @@ class SiteListTile extends ConsumerStatefulWidget { this.showSharedBadge = false, }); - String? get _depthString { - if (minDepth != null && maxDepth != null) { - return '${minDepth!.toStringAsFixed(0)}-${maxDepth!.toStringAsFixed(0)}m'; - } - if (maxDepth != null) { - return '${maxDepth!.toStringAsFixed(0)}m'; - } - return null; - } - bool get _hasLocation => latitude != null && longitude != null; @override @@ -1322,6 +1318,24 @@ class SiteListTile extends ConsumerStatefulWidget { class _SiteListTileState extends ConsumerState { final MapController _mapController = MapController(); + /// Depth summary in the diver's chosen unit. + /// + /// Depths are stored in meters, so they must be converted before display. + /// A range carries a single trailing symbol ("16-98ft"), matching how the + /// table view renders the same two columns. + String? _depthString(UnitFormatter units) { + final minDepth = widget.minDepth; + final maxDepth = widget.maxDepth; + if (minDepth != null && maxDepth != null) { + final min = units.convertDepth(minDepth).toStringAsFixed(0); + return '$min-${units.formatDepth(maxDepth, decimals: 0)}'; + } + if (maxDepth != null) { + return units.formatDepth(maxDepth, decimals: 0); + } + return null; + } + @override Widget build(BuildContext context) { final name = widget.name; @@ -1338,6 +1352,9 @@ class _SiteListTileState extends ConsumerState { final showSharedBadge = widget.showSharedBadge; final colorScheme = Theme.of(context).colorScheme; + final depthString = _depthString( + UnitFormatter(ref.watch(settingsProvider)), + ); final showMapBackground = ref.watch(showMapBackgroundOnSiteCardsProvider); final shouldShowMap = showMapBackground && widget._hasLocation && !isSelected && !isChecked; @@ -1410,9 +1427,9 @@ class _SiteListTileState extends ConsumerState { color: Theme.of(context).colorScheme.primary, ), ), - if (widget._depthString != null) + if (depthString != null) Text( - widget._depthString!, + depthString, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: secondaryTextColor, fontWeight: FontWeight.w500, diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 31a1eea7b6..2d37d4a1a8 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2831,9 +2831,9 @@ "diveSites_import_snackbar_viewAction": "عرض", "diveSites_list_activeFilter_clear": "مسح", "diveSites_list_activeFilter_country": "الدولة: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}م", - "diveSites_list_activeFilter_depthRangeMax": "حتى {max}م", - "diveSites_list_activeFilter_depthRangeMin": "{min}م+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "حتى {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "يحتوي على إحداثيات", "diveSites_list_activeFilter_hasDives": "يحتوي على غوصات", "diveSites_list_activeFilter_region": "المنطقة: {region}", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index cce9cd6bff..8dff17a795 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -2831,9 +2831,9 @@ "diveSites_import_snackbar_viewAction": "Anzeigen", "diveSites_list_activeFilter_clear": "Löschen", "diveSites_list_activeFilter_country": "Land: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Bis zu {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Bis zu {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Hat Koordinaten", "diveSites_list_activeFilter_hasDives": "Hat Tauchgänge", "diveSites_list_activeFilter_region": "Region: {region}", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7f81814529..5822a527b7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4907,9 +4907,9 @@ "diveSites_import_snackbar_viewAction": "View", "diveSites_list_activeFilter_clear": "Clear", "diveSites_list_activeFilter_country": "Country: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Up to {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Up to {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Has coordinates", "diveSites_list_activeFilter_hasDives": "Has dives", "diveSites_list_activeFilter_region": "Region: {region}", @@ -5140,6 +5140,7 @@ } }, "@diveSites_list_activeFilter_depthRangeBoth": { + "description": "Active depth filter chip for a two-ended range. Both values arrive already converted to the diver's depth unit; max already carries the unit symbol, so do not add one.", "placeholders": { "min": { "type": "Object" @@ -5150,6 +5151,7 @@ } }, "@diveSites_list_activeFilter_depthRangeMax": { + "description": "Active depth filter chip for an upper bound only. The value arrives already converted and already carries the diver's depth unit symbol, so do not add one.", "placeholders": { "max": { "type": "Object" @@ -5157,6 +5159,7 @@ } }, "@diveSites_list_activeFilter_depthRangeMin": { + "description": "Active depth filter chip for a lower bound only. The value arrives already converted and already carries the diver's depth unit symbol, so do not add one.", "placeholders": { "min": { "type": "Object" diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 5aaa777f1a..30cb99c311 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2831,9 +2831,9 @@ "diveSites_import_snackbar_viewAction": "Ver", "diveSites_list_activeFilter_clear": "Borrar", "diveSites_list_activeFilter_country": "Pais: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Hasta {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Hasta {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Tiene coordenadas", "diveSites_list_activeFilter_hasDives": "Tiene inmersiones", "diveSites_list_activeFilter_region": "Region: {region}", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index e7cb4d74b9..dbf8c6029f 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -2758,9 +2758,9 @@ "diveSites_import_snackbar_viewAction": "Voir", "diveSites_list_activeFilter_clear": "Effacer", "diveSites_list_activeFilter_country": "Pays : {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Jusqu'à {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Jusqu'à {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Avec coordonnees", "diveSites_list_activeFilter_hasDives": "Avec plongees", "diveSites_list_activeFilter_region": "Region : {region}", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 2e2e7487e9..00e80d2c03 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -2758,9 +2758,9 @@ "diveSites_import_snackbar_viewAction": "צפה", "diveSites_list_activeFilter_clear": "נקה", "diveSites_list_activeFilter_country": "מדינה: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}מ'", - "diveSites_list_activeFilter_depthRangeMax": "עד {max}מ'", - "diveSites_list_activeFilter_depthRangeMin": "{min}מ'+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "עד {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "יש קואורדינטות", "diveSites_list_activeFilter_hasDives": "יש צלילות", "diveSites_list_activeFilter_region": "אזור: {region}", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 9a3b21996b..27b0a9ff53 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -2758,9 +2758,9 @@ "diveSites_import_snackbar_viewAction": "Megtekintes", "diveSites_list_activeFilter_clear": "Torles", "diveSites_list_activeFilter_country": "Orszag: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Legfeljebb {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Legfeljebb {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Van koordinata", "diveSites_list_activeFilter_hasDives": "Vannak merulesek", "diveSites_list_activeFilter_region": "Regio: {region}", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index d7ad05eb0b..46281875a5 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -2758,9 +2758,9 @@ "diveSites_import_snackbar_viewAction": "Visualizza", "diveSites_list_activeFilter_clear": "Cancella", "diveSites_list_activeFilter_country": "Paese: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Fino a {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Fino a {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Ha coordinate", "diveSites_list_activeFilter_hasDives": "Ha immersioni", "diveSites_list_activeFilter_region": "Regione: {region}", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index f56d466905..5b5a83cf5f 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -14579,22 +14579,22 @@ abstract class AppLocalizations { /// **'Country: {country}'** String diveSites_list_activeFilter_country(Object country); - /// No description provided for @diveSites_list_activeFilter_depthRangeBoth. + /// Active depth filter chip for a two-ended range. Both values arrive already converted to the diver's depth unit; max already carries the unit symbol, so do not add one. /// /// In en, this message translates to: - /// **'{min}-{max}m'** + /// **'{min}-{max}'** String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max); - /// No description provided for @diveSites_list_activeFilter_depthRangeMax. + /// Active depth filter chip for an upper bound only. The value arrives already converted and already carries the diver's depth unit symbol, so do not add one. /// /// In en, this message translates to: - /// **'Up to {max}m'** + /// **'Up to {max}'** String diveSites_list_activeFilter_depthRangeMax(Object max); - /// No description provided for @diveSites_list_activeFilter_depthRangeMin. + /// Active depth filter chip for a lower bound only. The value arrives already converted and already carries the diver's depth unit symbol, so do not add one. /// /// In en, this message translates to: - /// **'{min}m+'** + /// **'{min}+'** String diveSites_list_activeFilter_depthRangeMin(Object min); /// No description provided for @diveSites_list_activeFilter_hasCoordinates. diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index c0bb6cb400..5e2d05392f 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8477,17 +8477,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-$maxم'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'حتى $maxم'; + return 'حتى $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '$minم+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 90520ce6d8..c37560c8f2 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8633,17 +8633,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Bis zu ${max}m'; + return 'Bis zu $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 5f98918bde..9ccf622dbd 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8492,17 +8492,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Up to ${max}m'; + return 'Up to $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index aeb5e0f185..d3c29f3726 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8642,17 +8642,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Hasta ${max}m'; + return 'Hasta $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 44cc51ffef..e3ffb8fbdb 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8674,17 +8674,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Jusqu\'à ${max}m'; + return 'Jusqu\'à $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 2494f4d7b0..efcb2fa427 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8427,17 +8427,17 @@ class AppLocalizationsHe extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-$maxמ\''; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'עד $maxמ\''; + return 'עד $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '$minמ\'+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 732d13a38e..74feb717b9 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8622,17 +8622,17 @@ class AppLocalizationsHu extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Legfeljebb ${max}m'; + return 'Legfeljebb $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 080ca4d3d5..f3b20b8ab7 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8642,17 +8642,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Fino a ${max}m'; + return 'Fino a $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 7bd4220c1d..c8db1b58f2 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8573,17 +8573,17 @@ class AppLocalizationsNl extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Tot ${max}m'; + return 'Tot $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 3b79720ce1..cbf7c2f7db 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8644,17 +8644,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return 'Até ${max}m'; + return 'Até $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index a709296041..2de81d2c2b 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8232,17 +8232,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String diveSites_list_activeFilter_depthRangeBoth(Object min, Object max) { - return '$min-${max}m'; + return '$min-$max'; } @override String diveSites_list_activeFilter_depthRangeMax(Object max) { - return '深度不超过 ${max}m'; + return '深度不超过 $max'; } @override String diveSites_list_activeFilter_depthRangeMin(Object min) { - return '${min}m+'; + return '$min+'; } @override diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 6e13d12895..fc0a07071f 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -2831,9 +2831,9 @@ "diveSites_import_snackbar_viewAction": "Bekijken", "diveSites_list_activeFilter_clear": "Wissen", "diveSites_list_activeFilter_country": "Land: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Tot {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Tot {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Heeft coordinaten", "diveSites_list_activeFilter_hasDives": "Heeft duiken", "diveSites_list_activeFilter_region": "Regio: {region}", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 1860259470..376335cfb2 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2831,9 +2831,9 @@ "diveSites_import_snackbar_viewAction": "Ver", "diveSites_list_activeFilter_clear": "Limpar", "diveSites_list_activeFilter_country": "Pais: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "Até {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "Até {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "Possui coordenadas", "diveSites_list_activeFilter_hasDives": "Possui mergulhos", "diveSites_list_activeFilter_region": "Regiao: {region}", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 141583486e..b47336d29b 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2964,9 +2964,9 @@ "diveSites_import_snackbar_viewAction": "查看", "diveSites_list_activeFilter_clear": "清除", "diveSites_list_activeFilter_country": "国家: {country}", - "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}m", - "diveSites_list_activeFilter_depthRangeMax": "深度不超过 {max}m", - "diveSites_list_activeFilter_depthRangeMin": "{min}m+", + "diveSites_list_activeFilter_depthRangeBoth": "{min}-{max}", + "diveSites_list_activeFilter_depthRangeMax": "深度不超过 {max}", + "diveSites_list_activeFilter_depthRangeMin": "{min}+", "diveSites_list_activeFilter_hasCoordinates": "有坐标", "diveSites_list_activeFilter_hasDives": "有潜水", "diveSites_list_activeFilter_region": "地区: {region}", diff --git a/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart b/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart new file mode 100644 index 0000000000..8f71bb0ff1 --- /dev/null +++ b/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/dive_sites/presentation/widgets/site_filter_sheet.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +/// Opens the sheet the way the site list does: as a modal bottom sheet handed +/// a [WidgetRef]. Going through a real route keeps "Apply"'s Navigator.pop +/// legitimate. +class _SheetLauncher extends ConsumerWidget { + const _SheetLauncher(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => SiteFilterSheet(ref: ref), + ), + child: const Text('open'), + ), + ), + ); + } +} + +Future _container({ + required AppSettings settings, + SiteFilterState filter = const SiteFilterState(), +}) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + return ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + settingsProvider.overrideWith((ref) => MockSettingsNotifier(settings)), + siteFilterProvider.overrideWith((ref) => filter), + ], + ); +} + +Widget _app(ProviderContainer container) { + return UncontrolledProviderScope( + container: container, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: _SheetLauncher(), + ), + ); +} + +/// The sheet is taller than a default test window; its overflow is a layout +/// artifact of the surface size, not the behavior under test. +void _useTallSurface(WidgetTester tester) { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1000, 2200); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final originalOnError = FlutterError.onError; + FlutterError.onError = (details) { + if (details.toString().contains('overflowed')) return; + originalOnError?.call(details); + }; + addTearDown(() => FlutterError.onError = originalOnError); +} + +Future _openSheet( + WidgetTester tester, + ProviderContainer container, +) async { + await tester.pumpWidget(_app(container)); + await tester.pumpAndSettle(); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + group('depth filter unit handling (issue #1257)', () { + testWidgets('suffixes the depth fields with the diver depth unit', ( + tester, + ) async { + _useTallSurface(tester); + final container = await _container( + settings: const AppSettings(depthUnit: DepthUnit.feet), + ); + addTearDown(container.dispose); + await _openSheet(tester, container); + + expect(find.text('ft'), findsNWidgets(2)); + expect(find.text('m'), findsNothing); + }); + + testWidgets('seeds the fields by converting the stored meter bounds', ( + tester, + ) async { + _useTallSurface(tester); + final container = await _container( + settings: const AppSettings(depthUnit: DepthUnit.feet), + filter: const SiteFilterState(minDepth: 5, maxDepth: 30), + ); + addTearDown(container.dispose); + await _openSheet(tester, container); + + // 5 m -> 16.40 ft, 30 m -> 98.43 ft, rounded to whole units. + expect(find.widgetWithText(TextField, '16'), findsOneWidget); + expect(find.widgetWithText(TextField, '98'), findsOneWidget); + }); + + testWidgets('converts a depth typed in feet back to meters on apply', ( + tester, + ) async { + _useTallSurface(tester); + final container = await _container( + settings: const AppSettings(depthUnit: DepthUnit.feet), + ); + addTearDown(container.dispose); + await _openSheet(tester, container); + + await tester.enterText(find.widgetWithText(TextField, 'Max'), '100'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply Filters')); + await tester.pumpAndSettle(); + + // 100 ft -> 30.48 m, which is what the site query compares against. + final applied = container.read(siteFilterProvider).maxDepth; + expect(applied, isNotNull); + expect(applied!, closeTo(30.48, 0.01)); + }); + + testWidgets('leaves a metric diver typing meters untouched', ( + tester, + ) async { + _useTallSurface(tester); + final container = await _container(settings: const AppSettings()); + addTearDown(container.dispose); + await _openSheet(tester, container); + + await tester.enterText(find.widgetWithText(TextField, 'Max'), '30'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply Filters')); + await tester.pumpAndSettle(); + + expect(container.read(siteFilterProvider).maxDepth, closeTo(30, 0.001)); + }); + }); +} diff --git a/test/features/dive_sites/presentation/widgets/site_list_content_test.dart b/test/features/dive_sites/presentation/widgets/site_list_content_test.dart index 5008468923..872005332f 100644 --- a/test/features/dive_sites/presentation/widgets/site_list_content_test.dart +++ b/test/features/dive_sites/presentation/widgets/site_list_content_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; +import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; @@ -40,9 +41,17 @@ SiteWithDiveCount _makeSite({ required String name, int diveCount = 0, bool isShared = false, + double? minDepth, + double? maxDepth, }) { return SiteWithDiveCount( - site: DiveSite(id: id, name: name, isShared: isShared), + site: DiveSite( + id: id, + name: name, + isShared: isShared, + minDepth: minDepth, + maxDepth: maxDepth, + ), diveCount: diveCount, ); } @@ -62,13 +71,16 @@ Future> _buildPhoneOverrides({ required ListViewMode viewMode, String? highlightedSiteId, List? divers, + AppSettings? settings, + SiteFilterState? filter, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); return [ sharedPreferencesProvider.overrideWithValue(prefs), - settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + if (filter != null) siteFilterProvider.overrideWith((ref) => filter), + settingsProvider.overrideWith((ref) => MockSettingsNotifier(settings)), currentDiverIdProvider.overrideWith((ref) => MockCurrentDiverIdNotifier()), sortedSitesWithCountsProvider.overrideWithValue(AsyncValue.data(sites)), siteListNotifierProvider.overrideWith((ref) => _MockSiteListNotifier()), @@ -1023,6 +1035,137 @@ void main() { } }); }); + + // --------------------------------------------------------------------------- + // Depth unit localization (issue #1257) + // --------------------------------------------------------------------------- + + group('detailed view depth respects the diver depth unit', () { + testWidgets('renders a max-only depth in feet when the diver is imperial', ( + tester, + ) async { + _setMobileTestSurfaceSize(tester); + final overrides = await _buildPhoneOverrides( + sites: [_makeSite(id: 's1', name: 'Alpha Site', maxDepth: 40)], + viewMode: ListViewMode.detailed, + settings: const AppSettings(depthUnit: DepthUnit.feet), + ); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SiteListContent(showAppBar: false), + ), + ); + await tester.pumpAndSettle(); + + // 40 m -> 131.23 ft, rendered without decimals. + expect(find.text('131ft'), findsOneWidget); + expect(find.text('40m'), findsNothing); + }); + + testWidgets('renders a depth range in feet with a single trailing symbol', ( + tester, + ) async { + _setMobileTestSurfaceSize(tester); + final overrides = await _buildPhoneOverrides( + sites: [ + _makeSite(id: 's1', name: 'Alpha Site', minDepth: 5, maxDepth: 30), + ], + viewMode: ListViewMode.detailed, + settings: const AppSettings(depthUnit: DepthUnit.feet), + ); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SiteListContent(showAppBar: false), + ), + ); + await tester.pumpAndSettle(); + + // 5 m -> 16.40 ft, 30 m -> 98.43 ft. + expect(find.text('16-98ft'), findsOneWidget); + }); + + testWidgets('still renders meters for a metric diver', (tester) async { + _setMobileTestSurfaceSize(tester); + final overrides = await _buildPhoneOverrides( + sites: [ + _makeSite(id: 's1', name: 'Alpha Site', minDepth: 5, maxDepth: 30), + ], + viewMode: ListViewMode.detailed, + settings: const AppSettings(), + ); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SiteListContent(showAppBar: false), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('5-30m'), findsOneWidget); + }); + }); + + group('active depth filter chip respects the diver depth unit', () { + testWidgets('labels a both-ended range in feet', (tester) async { + _setMobileTestSurfaceSize(tester); + final overrides = await _buildPhoneOverrides( + sites: [_makeSite(id: 's1', name: 'Alpha Site', maxDepth: 20)], + viewMode: ListViewMode.detailed, + settings: const AppSettings(depthUnit: DepthUnit.feet), + // Filter bounds are stored in meters, like every other depth value. + filter: const SiteFilterState(minDepth: 5, maxDepth: 30), + ); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SiteListContent(showAppBar: false), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('16-98ft'), findsOneWidget); + }); + + testWidgets('labels a max-only bound in feet', (tester) async { + _setMobileTestSurfaceSize(tester); + final overrides = await _buildPhoneOverrides( + sites: [_makeSite(id: 's1', name: 'Alpha Site', maxDepth: 20)], + viewMode: ListViewMode.detailed, + settings: const AppSettings(depthUnit: DepthUnit.feet), + filter: const SiteFilterState(maxDepth: 30), + ); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SiteListContent(showAppBar: false), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Up to 98ft'), findsOneWidget); + }); + + testWidgets('labels a min-only bound in feet', (tester) async { + _setMobileTestSurfaceSize(tester); + final overrides = await _buildPhoneOverrides( + sites: [_makeSite(id: 's1', name: 'Alpha Site', maxDepth: 20)], + viewMode: ListViewMode.detailed, + settings: const AppSettings(depthUnit: DepthUnit.feet), + filter: const SiteFilterState(minDepth: 5), + ); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SiteListContent(showAppBar: false), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('16ft+'), findsOneWidget); + }); + }); } class _SiteListSelectionHarness extends StatefulWidget { From d8cfe83391c54cf9a597696219e6f12561bf96b1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 23:36:03 -0400 Subject: [PATCH 014/122] refactor(sites): move Site Features section below depth stats Site Features previously rendered directly under the map, above Basic Info, which pushed the site's identity and core stats below the fold. It now sits after the Depth and Altitude sections and before Tide. The section had been nested inside the map's site.hasCoordinates spread and shared that guard. Moving it out gives it its own hasCoordinates check, which it still needs because its add action opens the fullscreen scape and that has nothing to render without a location. --- .../presentation/pages/site_detail_page.dart | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/features/dive_sites/presentation/pages/site_detail_page.dart b/lib/features/dive_sites/presentation/pages/site_detail_page.dart index 4df759cc23..7544bd4c69 100644 --- a/lib/features/dive_sites/presentation/pages/site_detail_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_detail_page.dart @@ -177,14 +177,6 @@ class _SiteDetailContentState extends ConsumerState<_SiteDetailContent> { if (site.hasCoordinates) ...[ _buildMapSection(context, ref, site), const SizedBox(height: 16), - // Diver-placed annotations; placement happens on the map, so - // the add action opens the fullscreen scape armed to place. - SiteFeaturesSection( - siteId: site.id, - onAddFeature: () => - _showFullscreenMap(context, ref, site, startPlacing: true), - ), - const SizedBox(height: 16), ], // Basic Info Section (Name + Location String) @@ -213,6 +205,18 @@ class _SiteDetailContentState extends ConsumerState<_SiteDetailContent> { const SizedBox(height: 16), ], + // Site Features Section (diver-placed annotations; placement happens + // on the map, so the add action opens the fullscreen scape armed to + // place) + if (site.hasCoordinates) ...[ + SiteFeaturesSection( + siteId: site.id, + onAddFeature: () => + _showFullscreenMap(context, ref, site, startPlacing: true), + ), + const SizedBox(height: 16), + ], + // Tide Section (only for non-freshwater sites with coordinates: // a quarry or lake has no tides, and a nearby ocean station must // not leak in) From b6ae794d9001a1c23a6b24e3bdec6b4e074e43b7 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Mon, 24 Aug 2026 23:46:58 -0400 Subject: [PATCH 015/122] chore(android): declare INTERNET in the main manifest Release builds already carry this permission. The manifest merger unions it in from a library manifest, and apkanalyzer confirms it on the shipped v1.7.5.6772 APK, so this is not a behaviour change and the update check was never going to fail for want of it. Declaring it explicitly removes a dependency nobody chose: the only pub package in the tree whose manifest supplies INTERNET is google_sign_in_android, which is transitive via google_sign_in. An app whose core features are cloud sync, media upload and update checks should not inherit its networking permission from a sign-in plugin it might one day drop. Raised in review on #1261. --- android/app/src/main/AndroidManifest.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6bd2cee3d6..6d6dbc6121 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,13 @@ + + From 4b1bef554b69cd5864a431f8b351d835ce9c37ca Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:14:17 +0000 Subject: [PATCH 016/122] Apply dart format after merging origin/main --- lib/features/dive_log/domain/models/dive_filter_state.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/features/dive_log/domain/models/dive_filter_state.dart b/lib/features/dive_log/domain/models/dive_filter_state.dart index 1dd5ae7db6..c500940304 100644 --- a/lib/features/dive_log/domain/models/dive_filter_state.dart +++ b/lib/features/dive_log/domain/models/dive_filter_state.dart @@ -24,6 +24,7 @@ class DiveFilterState { /// unrecorded (no profile, or a profile needing the computed fallback) /// match neither. final bool? decoOnly; + /// True to restrict the list to dives with no buddy assigned: neither the /// legacy free-text `buddy` field nor a linked buddy is set. final bool? noBuddyOnly; From 8cb36980b81e02e9585e6c958e3c8617193adfb1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:03:01 +0000 Subject: [PATCH 017/122] Fix format and weekday-filter test regression in Advanced Search dart format the two new weekday-filter test files, and switch tapFiveStars() to scrollUntilVisible so it builds list items as it scrolls instead of requiring the Organization section's star row to already exist in the ListView's cache extent. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- .../dive_repository_weekday_filter_test.dart | 8 ++------ .../pages/dive_search_page_filter_target_test.dart | 11 +++++++++-- .../widgets/weekday_filter_selector_test.dart | 4 +--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart b/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart index f8542d99e3..0f62368f68 100644 --- a/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart +++ b/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart @@ -36,9 +36,7 @@ void main() { final mondayInRange = DateTime(2026, 6, 8); final mondayOutOfRange = DateTime(2026, 7, 6); final tuesdayInRange = DateTime(2026, 6, 9); - await repository.createDive( - domain.Dive(id: 'd1', dateTime: mondayInRange), - ); + await repository.createDive(domain.Dive(id: 'd1', dateTime: mondayInRange)); await repository.createDive( domain.Dive(id: 'd2', dateTime: mondayOutOfRange), ); @@ -63,9 +61,7 @@ void main() { domain.Dive(id: 'a', dateTime: monday), domain.Dive(id: 'b', dateTime: tuesday), ]; - final filtered = DiveFilterState( - weekdays: [monday.weekday], - ).apply(dives); + final filtered = DiveFilterState(weekdays: [monday.weekday]).apply(dives); expect(filtered.map((d) => d.id), ['a']); }); } diff --git a/test/features/dive_log/presentation/pages/dive_search_page_filter_target_test.dart b/test/features/dive_log/presentation/pages/dive_search_page_filter_target_test.dart index 77fcacebe0..54581da5b5 100644 --- a/test/features/dive_log/presentation/pages/dive_search_page_filter_target_test.dart +++ b/test/features/dive_log/presentation/pages/dive_search_page_filter_target_test.dart @@ -100,9 +100,16 @@ void main() { /// from both seeds, making the write target unambiguous. Future tapFiveStars(WidgetTester tester) async { // The Organization section auto-expands because both seeds set a rating, - // but the star row still sits below the viewport on a phone-sized surface. + // but the star row sits far enough below the viewport that the plain + // ListView hasn't built its element yet, so `ensureVisible` (which needs + // an existing element) can't find it. Scroll incrementally instead so + // the list keeps building content as it goes. final fiveStars = find.byTooltip('5 stars'); - await tester.ensureVisible(fiveStars); + await tester.scrollUntilVisible( + fiveStars, + 100, + scrollable: find.byType(Scrollable).first, + ); await tester.pumpAndSettle(); await tester.tap(fiveStars); await tester.pumpAndSettle(); diff --git a/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart b/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart index 8c8e28ed9d..2a62c97755 100644 --- a/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart +++ b/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart @@ -50,9 +50,7 @@ void main() { : materialLocalizations.firstDayOfWeekIndex; final expectedFirstLabel = weekdayAbbreviation(context, firstWeekday); - final firstChip = tester.widget( - find.byType(FilterChip).first, - ); + final firstChip = tester.widget(find.byType(FilterChip).first); final labelText = (firstChip.label as Text).data; expect(labelText, expectedFirstLabel); From 809bde58b244c5c818eb44a48760b4c564d0837e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:16:58 +0000 Subject: [PATCH 018/122] Format buddy favorites/sort files per dart format CI's format check failed because these files weren't run through dart format before the initial push. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- .../providers/buddy_providers.dart | 5 +- .../migration_v161_buddy_favorite_test.dart | 86 ++++++++----------- .../repositories/buddy_repository_test.dart | 10 +-- .../providers/buddy_providers_test.dart | 6 +- .../buddy_picker_chip_interactions_test.dart | 4 +- .../widgets/buddy_picker_test.dart | 7 +- 6 files changed, 47 insertions(+), 71 deletions(-) diff --git a/lib/features/buddies/presentation/providers/buddy_providers.dart b/lib/features/buddies/presentation/providers/buddy_providers.dart index b86bf4d126..e58b68c373 100644 --- a/lib/features/buddies/presentation/providers/buddy_providers.dart +++ b/lib/features/buddies/presentation/providers/buddy_providers.dart @@ -63,10 +63,7 @@ final allBuddiesWithDiveCountProvider = /// Search results with dive counts, for the "Add buddy" picker sheet, which /// sorts by dive count and needs that even while a search query is active. final buddySearchWithDiveCountProvider = - FutureProvider.family, String>(( - ref, - query, - ) async { + FutureProvider.family, String>((ref, query) async { if (query.isEmpty) { return ref.watch(allBuddiesWithDiveCountProvider).value ?? []; } diff --git a/test/core/database/migration_v161_buddy_favorite_test.dart b/test/core/database/migration_v161_buddy_favorite_test.dart index 5e46fec2ad..dff00ac208 100644 --- a/test/core/database/migration_v161_buddy_favorite_test.dart +++ b/test/core/database/migration_v161_buddy_favorite_test.dart @@ -34,70 +34,60 @@ void main() { expect(column.read('dflt_value'), '0'); }); - test( - 'a database stranded at v160 gains the column via onUpgrade and ' - 'existing rows default to not-favorited', - () async { - final nativeDb = NativeDatabase.memory( - setup: (rawDb) { - rawDb.execute('PRAGMA user_version = 160'); - rawDb.execute(''' + test('a database stranded at v160 gains the column via onUpgrade and ' + 'existing rows default to not-favorited', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 160'); + rawDb.execute(''' CREATE TABLE buddies ( id TEXT NOT NULL PRIMARY KEY, diver_id TEXT, name TEXT NOT NULL, email TEXT, phone TEXT, photo_path TEXT, notes TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, hlc TEXT) '''); - rawDb.execute( - "INSERT INTO buddies (id, name, created_at, updated_at) " - "VALUES ('b1', 'B1', 0, 0)", - ); - }, - ); - final db = AppDatabase(nativeDb); - addTearDown(db.close); + rawDb.execute( + "INSERT INTO buddies (id, name, created_at, updated_at) " + "VALUES ('b1', 'B1', 0, 0)", + ); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); - final cols = await db - .customSelect("PRAGMA table_info('buddies')") - .get(); - final names = cols.map((c) => c.read('name')).toSet(); - expect(names, contains('is_favorite')); + final cols = await db.customSelect("PRAGMA table_info('buddies')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('is_favorite')); - final row = await db - .customSelect("SELECT is_favorite FROM buddies WHERE id = 'b1'") - .getSingle(); - expect(row.read('is_favorite'), 0); - }, - ); + final row = await db + .customSelect("SELECT is_favorite FROM buddies WHERE id = 'b1'") + .getSingle(); + expect(row.read('is_favorite'), 0); + }); - test( - 'beforeOpen backstop adds the column when a parallel-branch collision ' - 'stranded a DB past v161 without running the onUpgrade block', - () async { - final nativeDb = NativeDatabase.memory( - setup: (rawDb) { - rawDb.execute( - 'PRAGMA user_version = ${AppDatabase.currentSchemaVersion}', - ); - rawDb.execute(''' + test('beforeOpen backstop adds the column when a parallel-branch collision ' + 'stranded a DB past v161 without running the onUpgrade block', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute( + 'PRAGMA user_version = ${AppDatabase.currentSchemaVersion}', + ); + rawDb.execute(''' CREATE TABLE buddies ( id TEXT NOT NULL PRIMARY KEY, diver_id TEXT, name TEXT NOT NULL, email TEXT, phone TEXT, photo_path TEXT, notes TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, hlc TEXT) '''); - }, - ); - final db = AppDatabase(nativeDb); - addTearDown(db.close); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); - final cols = await db - .customSelect("PRAGMA table_info('buddies')") - .get(); - final names = cols.map((c) => c.read('name')).toSet(); - expect(names, contains('is_favorite')); - }, - ); + final cols = await db.customSelect("PRAGMA table_info('buddies')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('is_favorite')); + }); test('the assert is a no-op when the buddies table is absent', () async { final nativeDb = NativeDatabase.memory( diff --git a/test/features/buddies/data/repositories/buddy_repository_test.dart b/test/features/buddies/data/repositories/buddy_repository_test.dart index 218f4c2cb1..46efb2a5a0 100644 --- a/test/features/buddies/data/repositories/buddy_repository_test.dart +++ b/test/features/buddies/data/repositories/buddy_repository_test.dart @@ -323,10 +323,7 @@ void main() { expect((await repository.getBuddyById(buddy.id))!.isFavorite, isTrue); await repository.toggleFavorite(buddy.id); - expect( - (await repository.getBuddyById(buddy.id))!.isFavorite, - isFalse, - ); + expect((await repository.getBuddyById(buddy.id))!.isFavorite, isFalse); }); test('setFavorite sets the flag explicitly', () async { @@ -338,10 +335,7 @@ void main() { expect((await repository.getBuddyById(buddy.id))!.isFavorite, isTrue); await repository.setFavorite(buddy.id, false); - expect( - (await repository.getBuddyById(buddy.id))!.isFavorite, - isFalse, - ); + expect((await repository.getBuddyById(buddy.id))!.isFavorite, isFalse); }); }); diff --git a/test/features/buddies/presentation/providers/buddy_providers_test.dart b/test/features/buddies/presentation/providers/buddy_providers_test.dart index ea6ddda6eb..30035abbb4 100644 --- a/test/features/buddies/presentation/providers/buddy_providers_test.dart +++ b/test/features/buddies/presentation/providers/buddy_providers_test.dart @@ -242,9 +242,9 @@ void main() { final container = makeContainer(); addTearDown(container.dispose); - await container.read(buddyListNotifierProvider.notifier).toggleFavorite( - buddy.id, - ); + await container + .read(buddyListNotifierProvider.notifier) + .toggleFavorite(buddy.id); final updated = await buddyRepo.getBuddyById(buddy.id); expect(updated!.isFavorite, isTrue); diff --git a/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart b/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart index 7f36e76c14..4c6cb5a9aa 100644 --- a/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_picker_chip_interactions_test.dart @@ -50,9 +50,7 @@ Widget _buildPicker({ allBuddiesWithDiveCountProvider.overrideWith( (ref) async => [BuddyWithDiveCount(buddy: _alice, diveCount: 0)], ), - buddySearchWithDiveCountProvider.overrideWith( - (ref, q) async => const [], - ), + buddySearchWithDiveCountProvider.overrideWith((ref, q) async => const []), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/features/buddies/presentation/widgets/buddy_picker_test.dart b/test/features/buddies/presentation/widgets/buddy_picker_test.dart index 98186b94a9..61b1967c1a 100644 --- a/test/features/buddies/presentation/widgets/buddy_picker_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_picker_test.dart @@ -98,9 +98,7 @@ void main() { allBuddiesWithDiveCountProvider.overrideWith( (ref) async => _testBuddiesWithCount, ), - buddySearchWithDiveCountProvider.overrideWith( - (ref, q) async => [], - ), + buddySearchWithDiveCountProvider.overrideWith((ref, q) async => []), ], ), ); @@ -444,8 +442,7 @@ void main() { return Future.value( _withCount( _testBuddies.where( - (b) => - b.name.toLowerCase().contains(query.toLowerCase()), + (b) => b.name.toLowerCase().contains(query.toLowerCase()), ), ), ); From c5cc716357fa3a7eea119c636b9748568418409f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 21:17:13 -0400 Subject: [PATCH 019/122] test: pin the en locale in the dive site depth unit widget tests The six new detailed-view and filter-chip tests, plus the filter sheet test harness, asserted English strings against a MaterialApp with no locale pinned. flutter_test forwards the host machine's locale list rather than a fixed en_US, so basicLocaleListResolution can land on any of the 11 supported locales and the ARB-backed assertions then fail. diveSites_list_activeFilter_depthRangeMax is "Up to {max}" in English and "Jusqu'a {max}" in French, so "Up to 98ft" is absent under a French host locale. Verified by flipping one pin to fr, which fails the test, and back to en, which passes. CI stays green either way because the runners are en_US, so this only bites a contributor locally. testApp already exposes a locale parameter and site_list_content_test already pins it at line 677; this extends that existing convention to the new cases. --- .../presentation/widgets/site_filter_sheet_test.dart | 3 +++ .../presentation/widgets/site_list_content_test.dart | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart b/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart index 8f71bb0ff1..d15a26b435 100644 --- a/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart +++ b/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart @@ -53,6 +53,9 @@ Widget _app(ProviderContainer container) { return UncontrolledProviderScope( container: container, child: const MaterialApp( + // Pinned so assertions on English labels ("Max", "Apply Filters") do not + // depend on the host machine's locale, which flutter_test forwards. + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: _SheetLauncher(), diff --git a/test/features/dive_sites/presentation/widgets/site_list_content_test.dart b/test/features/dive_sites/presentation/widgets/site_list_content_test.dart index 872005332f..243fcbbc6d 100644 --- a/test/features/dive_sites/presentation/widgets/site_list_content_test.dart +++ b/test/features/dive_sites/presentation/widgets/site_list_content_test.dart @@ -1053,6 +1053,7 @@ void main() { await tester.pumpWidget( testApp( overrides: overrides, + locale: const Locale('en'), child: const SiteListContent(showAppBar: false), ), ); @@ -1077,6 +1078,7 @@ void main() { await tester.pumpWidget( testApp( overrides: overrides, + locale: const Locale('en'), child: const SiteListContent(showAppBar: false), ), ); @@ -1098,6 +1100,7 @@ void main() { await tester.pumpWidget( testApp( overrides: overrides, + locale: const Locale('en'), child: const SiteListContent(showAppBar: false), ), ); @@ -1120,6 +1123,7 @@ void main() { await tester.pumpWidget( testApp( overrides: overrides, + locale: const Locale('en'), child: const SiteListContent(showAppBar: false), ), ); @@ -1139,6 +1143,7 @@ void main() { await tester.pumpWidget( testApp( overrides: overrides, + locale: const Locale('en'), child: const SiteListContent(showAppBar: false), ), ); @@ -1158,6 +1163,7 @@ void main() { await tester.pumpWidget( testApp( overrides: overrides, + locale: const Locale('en'), child: const SiteListContent(showAppBar: false), ), ); From 7f19e766c6f2ae23b93c4a9f2b2f8666bc6fa7b7 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 22:05:57 -0400 Subject: [PATCH 020/122] ci: ship standing App Review notes with every store submission iOS 1.7.4 and 1.7.5 were both rejected under guideline 5.1.1(v) ("account deletion") because a reviewer took the first-launch "Create Your Profile" step for account creation, and nothing told them otherwise: the repo had no App Review notes at all, and promote auto-resubmits after a rejection, so the 1.7.4 flag carried straight into 1.7.5 unanswered. Add fastlane/metadata/review_information/notes.txt on both platforms. deliver reads that directory from the metadata root and uploads it into App Review Information > Notes on every submission, so the explanation (no accounts; the profile is a local label with an in-app delete; what "Sign in" connects to; local protections; permissions) reaches Apple before the reviewer touches the build. scripts/release/app_review_notes_test.sh guards the file in CI: present on both platforms, byte-identical, within Apple's 4000-character limit, and passed through sanitize_apple_store_notes.py unchanged so no other platform name can reach Apple through this field. Correct the Fastfile and promote.yml comments that called release_notes.txt the only metadata file, and document the reply-don't- repromote handling for a misunderstanding-type rejection. --- .github/workflows/ci.yaml | 8 +++ .github/workflows/promote.yml | 3 +- docs/developer/release-process.md | 11 +++- ios/fastlane/Fastfile | 9 +++- .../metadata/review_information/notes.txt | 22 ++++++++ macos/fastlane/Fastfile | 9 +++- .../metadata/review_information/notes.txt | 22 ++++++++ scripts/release/app_review_notes_test.sh | 50 +++++++++++++++++++ 8 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 ios/fastlane/metadata/review_information/notes.txt create mode 100644 macos/fastlane/metadata/review_information/notes.txt create mode 100755 scripts/release/app_review_notes_test.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a1ae46a7db..fa5e394c53 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -416,6 +416,14 @@ jobs: # creating a new one and left it carrying the wrong build. run: ruby scripts/release/fastlane_submit_guard_test.rb + - name: Run App Review notes test + # Guards the committed App Review notes deliver uploads with every + # submission: present on both platforms, identical, within Apple's + # length limit, and free of other-platform names (2.3.10). iOS 1.7.4 + # and 1.7.5 were rejected under 5.1.1(v) because nothing told the + # reviewer the app has no accounts. + run: ./scripts/release/app_review_notes_test.sh + - name: Run pre-push hook test # Guards two hook bugs that both rejected pushes for reasons unrelated # to the change being pushed: running the checks against the main diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index aff8e87398..1745ad9a5c 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -289,7 +289,8 @@ jobs: set -euo pipefail # Apple requires whatsNew on a new App Store version; deliver reads # it from metadata/en-US/release_notes.txt (the only metadata file - # we provide, so nothing else is touched). + # generated here; the committed review_information/notes.txt beside + # it is uploaded as-is and must not be touched). # # The GitHub release body is written for every platform at once, and # App Review guideline 2.3.10 forbids naming other platforms in App diff --git a/docs/developer/release-process.md b/docs/developer/release-process.md index f969227422..48cb443c16 100644 --- a/docs/developer/release-process.md +++ b/docs/developer/release-process.md @@ -85,7 +85,16 @@ and the next merge to `main` starts the next beta train. Two things still want a human: - **A rejection.** Auto-release only covers the approved path. A rejected - version sits in App Store Connect until someone addresses it. + version sits in App Store Connect until someone addresses it. If the + rejection is a misunderstanding rather than a defect (1.7.4 and 1.7.5 were + both flagged under 5.1.1(v) because the first-launch "Create Your Profile" + step reads as account creation), reply in the Resolution Center thread and + do **not** re-run promote: the submission is still open, Apple approves + from the reply, and promote would cancel that submission and start over. + The standing explanation Apple reads before every review lives in + `ios/fastlane/metadata/review_information/notes.txt` (macOS has an + identical copy; `scripts/release/app_review_notes_test.sh` enforces + that). Update it whenever a reviewer needs context a reply had to supply. - **A bad build.** There is no staged rollout to halt and no rollback once a version is live. Recovering means pulling it and shipping a fix through another review cycle. If that risk ever outweighs the convenience, set diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 1a97f2e112..1ec88b3a46 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -677,8 +677,13 @@ platform :ios do skip_screenshots: true, # skip_metadata false so deliver uploads metadata/en-US/release_notes.txt # (written by promote.yml from the GitHub release notes) - Apple requires - # whatsNew on a new version. Only files present are uploaded, and that is - # the only metadata file provided. + # whatsNew on a new version. Only files present are uploaded. The other + # file provided is the committed metadata/review_information/notes.txt, + # which lands in App Review Information > Notes on every submission: it + # explains that the app has no accounts, after 1.7.4 and 1.7.5 were both + # rejected under 5.1.1(v) for the first-launch "Create Your Profile" + # step. scripts/release/app_review_notes_test.sh keeps it in lockstep + # with the macOS copy. skip_metadata: false, # Required BECAUSE skip_metadata is false. deliver renders the metadata # it is about to upload to fastlane/Preview.html and blocks on "Does the diff --git a/ios/fastlane/metadata/review_information/notes.txt b/ios/fastlane/metadata/review_information/notes.txt new file mode 100644 index 0000000000..a07e23a421 --- /dev/null +++ b/ios/fastlane/metadata/review_information/notes.txt @@ -0,0 +1,22 @@ +Submersion is a scuba dive logging app. It has no user accounts: no sign-up, no login, no demo account, and no backend service operated by us. Every dive log, profile, setting and photo link is stored locally on the device. + +ABOUT "CREATE YOUR PROFILE" (guideline 5.1.1(v)) +The first-launch step titled "Create Your Profile" creates a local diver profile: a name used to label dive logs when more than one person shares a device. It is not an account. The name is never transmitted to us and there is nothing to register with. + +Diver profiles are deleted in-app: Settings > Diver Profile > Manage Divers, choose the diver, then Delete. The confirmation asks the user to type "Delete ". Deletion permanently removes the profile and every dive log, dive computer, equipment item, certification and site attached to it. This works for the only profile on the device as well. A screen recording of this flow, captured on a physical device, was attached to the review reply for version 1.7.5 (submission ce6d5c46-26d8-4c48-851c-4654ff4e60f5). + +ABOUT "SIGN IN" WORDING +Where the app offers to sign in, it connects to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive that data. Those connections are removed at Settings > Photos & Media > Connected Accounts ("Remove from library"), which also discards the stored credentials. + +OPTIONAL LOCAL PROTECTIONS THAT ARE NOT ACCOUNTS +- App Lock sets a device-local password or Face ID unlock for the app. It can be turned off from Settings > Security. +- Backup encryption protects exported backup files with a user-chosen password. It can be turned off from the backup settings. + +PERMISSIONS REQUESTED AND WHY (all optional; the app is fully usable without any of them) +- Bluetooth: download dives from a dive computer. +- Location: record dive site coordinates and find nearby sites. +- Photos and camera: attach photos to a dive. +- Contacts: import dive buddies. +- Health: import dives recorded by Apple Watch. + +No demo credentials are needed. A fresh install with any name entered in the profile step reaches every feature. diff --git a/macos/fastlane/Fastfile b/macos/fastlane/Fastfile index 13085ec924..7642ab4933 100644 --- a/macos/fastlane/Fastfile +++ b/macos/fastlane/Fastfile @@ -769,8 +769,13 @@ platform :mac do skip_screenshots: true, # skip_metadata false so deliver uploads metadata/en-US/release_notes.txt # (written by promote.yml from the GitHub release notes) - Apple requires - # whatsNew on a new version. Only files present are uploaded, and that is - # the only metadata file provided. + # whatsNew on a new version. Only files present are uploaded. The other + # file provided is the committed metadata/review_information/notes.txt, + # which lands in App Review Information > Notes on every submission: it + # explains that the app has no accounts, after 1.7.4 and 1.7.5 were both + # rejected under 5.1.1(v) for the first-launch "Create Your Profile" + # step. scripts/release/app_review_notes_test.sh keeps it in lockstep + # with the iOS copy. skip_metadata: false, # Required BECAUSE skip_metadata is false. deliver renders the metadata # it is about to upload to fastlane/Preview.html and blocks on "Does the diff --git a/macos/fastlane/metadata/review_information/notes.txt b/macos/fastlane/metadata/review_information/notes.txt new file mode 100644 index 0000000000..a07e23a421 --- /dev/null +++ b/macos/fastlane/metadata/review_information/notes.txt @@ -0,0 +1,22 @@ +Submersion is a scuba dive logging app. It has no user accounts: no sign-up, no login, no demo account, and no backend service operated by us. Every dive log, profile, setting and photo link is stored locally on the device. + +ABOUT "CREATE YOUR PROFILE" (guideline 5.1.1(v)) +The first-launch step titled "Create Your Profile" creates a local diver profile: a name used to label dive logs when more than one person shares a device. It is not an account. The name is never transmitted to us and there is nothing to register with. + +Diver profiles are deleted in-app: Settings > Diver Profile > Manage Divers, choose the diver, then Delete. The confirmation asks the user to type "Delete ". Deletion permanently removes the profile and every dive log, dive computer, equipment item, certification and site attached to it. This works for the only profile on the device as well. A screen recording of this flow, captured on a physical device, was attached to the review reply for version 1.7.5 (submission ce6d5c46-26d8-4c48-851c-4654ff4e60f5). + +ABOUT "SIGN IN" WORDING +Where the app offers to sign in, it connects to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive that data. Those connections are removed at Settings > Photos & Media > Connected Accounts ("Remove from library"), which also discards the stored credentials. + +OPTIONAL LOCAL PROTECTIONS THAT ARE NOT ACCOUNTS +- App Lock sets a device-local password or Face ID unlock for the app. It can be turned off from Settings > Security. +- Backup encryption protects exported backup files with a user-chosen password. It can be turned off from the backup settings. + +PERMISSIONS REQUESTED AND WHY (all optional; the app is fully usable without any of them) +- Bluetooth: download dives from a dive computer. +- Location: record dive site coordinates and find nearby sites. +- Photos and camera: attach photos to a dive. +- Contacts: import dive buddies. +- Health: import dives recorded by Apple Watch. + +No demo credentials are needed. A fresh install with any name entered in the profile step reaches every feature. diff --git a/scripts/release/app_review_notes_test.sh b/scripts/release/app_review_notes_test.sh new file mode 100755 index 0000000000..7609cc1e42 --- /dev/null +++ b/scripts/release/app_review_notes_test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Guards the App Review notes that deliver uploads with every store +# submission from fastlane/metadata/review_information/notes.txt. +# +# Why they exist: iOS 1.7.4 and 1.7.5 were both rejected under guideline +# 5.1.1(v) ("account deletion") because a reviewer took the first-launch +# "Create Your Profile" step for account creation, and nothing told them +# otherwise. Apple reads these notes before touching the build, so keeping +# them accurate and present is the durable fix. +# +# What is checked: +# - Both platform copies exist and are non-empty, so a submission never goes +# up silent on one platform. deliver uploads only the files present, so a +# missing file is a missing field, not an error. +# - The two copies are byte-identical. There is one app and one explanation. +# - The text fits Apple's 4000-character limit for the field. +# - The 2.3.10 sanitizer passes the text through unchanged, so no other +# platform name can reach Apple through this field. Release notes are +# sanitized in CI; this file is hand-written and committed, so the check +# has to run here. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SANITIZE="$SCRIPT_DIR/sanitize_apple_store_notes.py" +IOS="$ROOT/ios/fastlane/metadata/review_information/notes.txt" +MACOS="$ROOT/macos/fastlane/metadata/review_information/notes.txt" +APPLE_LIMIT=4000 + +fail() { echo "FAIL: $1"; exit 1; } + +for f in "$IOS" "$MACOS"; do + [ -s "$f" ] || fail "missing or empty: ${f#"$ROOT"/}" +done + +cmp -s "$IOS" "$MACOS" \ + || fail "ios and macos review notes differ; keep them identical" + +# Characters, not bytes: the limit is on text, and a multi-byte character +# still counts once. +chars=$(python3 -c 'import sys; print(len(sys.stdin.read()))' < "$IOS") +[ "$chars" -le "$APPLE_LIMIT" ] \ + || fail "review notes are $chars characters; Apple's limit is $APPLE_LIMIT" + +sanitized=$("$SANITIZE" < "$IOS") +original=$(cat "$IOS") +[ "$sanitized" = "$original" ] \ + || fail "review notes name another platform or store (guideline 2.3.10); run: $SANITIZE --report < ${IOS#"$ROOT"/}" + +echo "app_review_notes_test: OK ($chars characters)" From 65fcbad1d982c2df8570ec32cbf9ab9297c631ab Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 22:10:04 -0400 Subject: [PATCH 021/122] ci: trim App Review notes to what pre-empts a rejection A reviewer skims this field in seconds, so every sentence should answer "which guideline does this pre-empt?". Drop the permissions list (the OS prompts already carry the purpose strings, and the 1.7.4 primer issue was wording that notes cannot fix) and the submission-ID reference to the recording (it goes stale). Keep the no-accounts statement, the profile deletion path, the "Sign in" explanation and the local-password note. --- .../metadata/review_information/notes.txt | 23 ++++--------------- .../metadata/review_information/notes.txt | 23 ++++--------------- 2 files changed, 10 insertions(+), 36 deletions(-) diff --git a/ios/fastlane/metadata/review_information/notes.txt b/ios/fastlane/metadata/review_information/notes.txt index a07e23a421..9314413bf4 100644 --- a/ios/fastlane/metadata/review_information/notes.txt +++ b/ios/fastlane/metadata/review_information/notes.txt @@ -1,22 +1,9 @@ -Submersion is a scuba dive logging app. It has no user accounts: no sign-up, no login, no demo account, and no backend service operated by us. Every dive log, profile, setting and photo link is stored locally on the device. +Submersion has no user accounts: no sign-up, no login, no demo account, and no backend service operated by us. All data is stored locally on the device. -ABOUT "CREATE YOUR PROFILE" (guideline 5.1.1(v)) -The first-launch step titled "Create Your Profile" creates a local diver profile: a name used to label dive logs when more than one person shares a device. It is not an account. The name is never transmitted to us and there is nothing to register with. +"CREATE YOUR PROFILE" (guideline 5.1.1(v)): the first-launch step creates a local diver profile, a name used to label dive logs when several people share a device. It is not an account and is never transmitted. Profiles are deleted in-app at Settings > Diver Profile > Manage Divers > select the diver > Delete (type "Delete " to confirm). This permanently removes the profile and all of its dive logs, equipment, certifications and sites, and works for the only profile on the device. -Diver profiles are deleted in-app: Settings > Diver Profile > Manage Divers, choose the diver, then Delete. The confirmation asks the user to type "Delete ". Deletion permanently removes the profile and every dive log, dive computer, equipment item, certification and site attached to it. This works for the only profile on the device as well. A screen recording of this flow, captured on a physical device, was attached to the review reply for version 1.7.5 (submission ce6d5c46-26d8-4c48-851c-4654ff4e60f5). +"SIGN IN": these options connect to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive it. Connections are removed at Settings > Photos & Media > Connected Accounts. -ABOUT "SIGN IN" WORDING -Where the app offers to sign in, it connects to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive that data. Those connections are removed at Settings > Photos & Media > Connected Accounts ("Remove from library"), which also discards the stored credentials. +App Lock (Settings > Security) and backup encryption are optional device-local passwords, not accounts, and can be turned off from the same screens. -OPTIONAL LOCAL PROTECTIONS THAT ARE NOT ACCOUNTS -- App Lock sets a device-local password or Face ID unlock for the app. It can be turned off from Settings > Security. -- Backup encryption protects exported backup files with a user-chosen password. It can be turned off from the backup settings. - -PERMISSIONS REQUESTED AND WHY (all optional; the app is fully usable without any of them) -- Bluetooth: download dives from a dive computer. -- Location: record dive site coordinates and find nearby sites. -- Photos and camera: attach photos to a dive. -- Contacts: import dive buddies. -- Health: import dives recorded by Apple Watch. - -No demo credentials are needed. A fresh install with any name entered in the profile step reaches every feature. +No demo credentials are needed; entering any name in the profile step reaches every feature. diff --git a/macos/fastlane/metadata/review_information/notes.txt b/macos/fastlane/metadata/review_information/notes.txt index a07e23a421..9314413bf4 100644 --- a/macos/fastlane/metadata/review_information/notes.txt +++ b/macos/fastlane/metadata/review_information/notes.txt @@ -1,22 +1,9 @@ -Submersion is a scuba dive logging app. It has no user accounts: no sign-up, no login, no demo account, and no backend service operated by us. Every dive log, profile, setting and photo link is stored locally on the device. +Submersion has no user accounts: no sign-up, no login, no demo account, and no backend service operated by us. All data is stored locally on the device. -ABOUT "CREATE YOUR PROFILE" (guideline 5.1.1(v)) -The first-launch step titled "Create Your Profile" creates a local diver profile: a name used to label dive logs when more than one person shares a device. It is not an account. The name is never transmitted to us and there is nothing to register with. +"CREATE YOUR PROFILE" (guideline 5.1.1(v)): the first-launch step creates a local diver profile, a name used to label dive logs when several people share a device. It is not an account and is never transmitted. Profiles are deleted in-app at Settings > Diver Profile > Manage Divers > select the diver > Delete (type "Delete " to confirm). This permanently removes the profile and all of its dive logs, equipment, certifications and sites, and works for the only profile on the device. -Diver profiles are deleted in-app: Settings > Diver Profile > Manage Divers, choose the diver, then Delete. The confirmation asks the user to type "Delete ". Deletion permanently removes the profile and every dive log, dive computer, equipment item, certification and site attached to it. This works for the only profile on the device as well. A screen recording of this flow, captured on a physical device, was attached to the review reply for version 1.7.5 (submission ce6d5c46-26d8-4c48-851c-4654ff4e60f5). +"SIGN IN": these options connect to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive it. Connections are removed at Settings > Photos & Media > Connected Accounts. -ABOUT "SIGN IN" WORDING -Where the app offers to sign in, it connects to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive that data. Those connections are removed at Settings > Photos & Media > Connected Accounts ("Remove from library"), which also discards the stored credentials. +App Lock (Settings > Security) and backup encryption are optional device-local passwords, not accounts, and can be turned off from the same screens. -OPTIONAL LOCAL PROTECTIONS THAT ARE NOT ACCOUNTS -- App Lock sets a device-local password or Face ID unlock for the app. It can be turned off from Settings > Security. -- Backup encryption protects exported backup files with a user-chosen password. It can be turned off from the backup settings. - -PERMISSIONS REQUESTED AND WHY (all optional; the app is fully usable without any of them) -- Bluetooth: download dives from a dive computer. -- Location: record dive site coordinates and find nearby sites. -- Photos and camera: attach photos to a dive. -- Contacts: import dive buddies. -- Health: import dives recorded by Apple Watch. - -No demo credentials are needed. A fresh install with any name entered in the profile step reaches every feature. +No demo credentials are needed; entering any name in the profile step reaches every feature. From 32833ac182afcde6f10f574905763105a27d1b9b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 22:12:12 -0400 Subject: [PATCH 022/122] ci: describe App Lock as password or biometric unlock in review notes Review feedback on PR #1277: the first draft named Face ID, which is wrong on macOS (Touch ID) and narrower than the app's own wording ("password or biometrics"). The trim had already dropped the mention; this restores the context in platform-neutral terms so a reviewer who meets a biometric prompt knows it is a local unlock, not an account. --- ios/fastlane/metadata/review_information/notes.txt | 2 +- macos/fastlane/metadata/review_information/notes.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/fastlane/metadata/review_information/notes.txt b/ios/fastlane/metadata/review_information/notes.txt index 9314413bf4..5965db55c4 100644 --- a/ios/fastlane/metadata/review_information/notes.txt +++ b/ios/fastlane/metadata/review_information/notes.txt @@ -4,6 +4,6 @@ Submersion has no user accounts: no sign-up, no login, no demo account, and no b "SIGN IN": these options connect to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive it. Connections are removed at Settings > Photos & Media > Connected Accounts. -App Lock (Settings > Security) and backup encryption are optional device-local passwords, not accounts, and can be turned off from the same screens. +App Lock (Settings > Security, a password or biometric unlock for the app) and backup encryption (a password on exported backup files) are optional device-local protections, not accounts, and can be turned off from the same screens. No demo credentials are needed; entering any name in the profile step reaches every feature. diff --git a/macos/fastlane/metadata/review_information/notes.txt b/macos/fastlane/metadata/review_information/notes.txt index 9314413bf4..5965db55c4 100644 --- a/macos/fastlane/metadata/review_information/notes.txt +++ b/macos/fastlane/metadata/review_information/notes.txt @@ -4,6 +4,6 @@ Submersion has no user accounts: no sign-up, no login, no demo account, and no b "SIGN IN": these options connect to a third-party storage account the user already owns (iCloud, Dropbox, Google Drive, or a user-supplied S3 bucket) so the user can sync or back up their own data to their own storage. We never receive it. Connections are removed at Settings > Photos & Media > Connected Accounts. -App Lock (Settings > Security) and backup encryption are optional device-local passwords, not accounts, and can be turned off from the same screens. +App Lock (Settings > Security, a password or biometric unlock for the app) and backup encryption (a password on exported backup files) are optional device-local protections, not accounts, and can be turned off from the same screens. No demo credentials are needed; entering any name in the profile step reaches every feature. From 158009093fe11c3ecb8095cc9651478a9dd082fa Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 22:35:37 -0400 Subject: [PATCH 023/122] fix(certifications): keep the certification visible when a custom name owns the title (#1265) certificationTitle prefers a custom stored name over the structured level, on the contract that the surface's secondary line carries the level via certificationSubtitle. Six list and summary surfaces never honoured it: they built their subtitle from the agency (plus a date) alone, so a card entered with a "Name on Card" of "Bill Ansell" showed PADI and a date, and its Divemaster level appeared nowhere on the tile. The gap has been there since the title helper landed, but was invisible while the edit form still auto-filled the name as "PADI : Divemaster": the title itself carried the level. Removing that auto-fill exposed it. Adds certificationAgencyAndLevel to the module that already owns naming decisions, so the anti-duplication rule is enforced once rather than re-argued at each call site, and routes the certification list tile, summary preview, picker tile, picker sheet, buddy detail and buddy edit through it. The two screen-reader labels gain the level on the same condition, so it is spoken exactly once either way. A derived title still gets the agency alone: certificationSubtitle returns null there, and the title already says the level. --- .../presentation/pages/buddy_detail_page.dart | 2 +- .../presentation/pages/buddy_edit_page.dart | 2 +- .../domain/certification_title.dart | 13 +++ .../widgets/certification_list_content.dart | 12 ++- .../widgets/certification_picker.dart | 14 ++- .../widgets/certification_summary_widget.dart | 2 +- .../domain/certification_title_test.dart | 24 +++++ .../certification_list_content_test.dart | 93 +++++++++++++++++++ 8 files changed, 153 insertions(+), 9 deletions(-) diff --git a/lib/features/buddies/presentation/pages/buddy_detail_page.dart b/lib/features/buddies/presentation/pages/buddy_detail_page.dart index cc8b2e786b..967505b4bd 100644 --- a/lib/features/buddies/presentation/pages/buddy_detail_page.dart +++ b/lib/features/buddies/presentation/pages/buddy_detail_page.dart @@ -505,7 +505,7 @@ class _BuddyDetailContent extends ConsumerWidget { contentPadding: EdgeInsets.zero, leading: const Icon(Icons.card_membership), title: Text(certificationTitle(cert)), - subtitle: Text(cert.agency.displayName), + subtitle: Text(certificationAgencyAndLevel(cert)), ), ], ), diff --git a/lib/features/buddies/presentation/pages/buddy_edit_page.dart b/lib/features/buddies/presentation/pages/buddy_edit_page.dart index de7207407c..b667d4fce2 100644 --- a/lib/features/buddies/presentation/pages/buddy_edit_page.dart +++ b/lib/features/buddies/presentation/pages/buddy_edit_page.dart @@ -431,7 +431,7 @@ class _BuddyEditPageState extends ConsumerState { contentPadding: EdgeInsets.zero, leading: const Icon(Icons.card_membership), title: Text(certificationTitle(cert)), - subtitle: Text(cert.agency.displayName), + subtitle: Text(certificationAgencyAndLevel(cert)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/certifications/domain/certification_title.dart b/lib/features/certifications/domain/certification_title.dart index 2345d2fbca..abc7cac2e1 100644 --- a/lib/features/certifications/domain/certification_title.dart +++ b/lib/features/certifications/domain/certification_title.dart @@ -63,3 +63,16 @@ String certificationTitle(Certification cert) => /// remove. String? certificationSubtitle(Certification cert) => customNameOrNull(cert) == null ? null : cert.level?.displayName; + +/// The agency line that list surfaces put beneath [certificationTitle], +/// carrying the level as well whenever the title is a custom name. +/// +/// A card stored as "Bill Ansell" takes the whole title, so without this the +/// level it was actually issued for (Divemaster) would appear nowhere on the +/// tile. [certificationSubtitle] returns null for a derived title, which +/// already names the level, so this never says it twice. +String certificationAgencyAndLevel(Certification cert) { + final level = certificationSubtitle(cert); + final agency = cert.agency.displayName; + return level == null ? agency : '$agency - $level'; +} diff --git a/lib/features/certifications/presentation/widgets/certification_list_content.dart b/lib/features/certifications/presentation/widgets/certification_list_content.dart index cb1472c381..aa9668c1d5 100644 --- a/lib/features/certifications/presentation/widgets/certification_list_content.dart +++ b/lib/features/certifications/presentation/widgets/certification_list_content.dart @@ -743,6 +743,11 @@ class CertificationListTile extends StatelessWidget { final issueDateLabel = certification.issueDate != null ? ', issued ${DateFormat.yMMMd().format(certification.issueDate!)}' : ''; + // Only non-null when a custom name owns the title, so the level is spoken + // exactly once either way. + final levelLabel = certificationSubtitle(certification) != null + ? ', ${certificationSubtitle(certification)}' + : ''; return Semantics( // Keep the agency: this label stands in for the whole tile, so dropping @@ -750,7 +755,8 @@ class CertificationListTile extends StatelessWidget { // derived rather than raw so the agency is not said twice. label: '${certification.agency.displayName} ' - '${certificationTitle(certification)}$issueDateLabel$statusLabel', + '${certificationTitle(certification)}' + '$levelLabel$issueDateLabel$statusLabel', child: Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), color: isSelected @@ -800,7 +806,9 @@ class CertificationListTile extends StatelessWidget { Widget? _buildSubtitle(BuildContext context) { final parts = []; - parts.add(certification.agency.displayName); + // Carries the level too when the title is a custom name, which is the + // only place the level can show on this tile. + parts.add(certificationAgencyAndLevel(certification)); if (certification.issueDate != null) { parts.add(DateFormat.yMMMd().format(certification.issueDate!)); } diff --git a/lib/features/certifications/presentation/widgets/certification_picker.dart b/lib/features/certifications/presentation/widgets/certification_picker.dart index 079db56bcb..6f96f0ed0f 100644 --- a/lib/features/certifications/presentation/widgets/certification_picker.dart +++ b/lib/features/certifications/presentation/widgets/certification_picker.dart @@ -43,7 +43,7 @@ class CertificationPicker extends ConsumerWidget { context.l10n.certifications_picker_noSelection, ), subtitle: selectedCertification != null - ? Text(selectedCertification!.agency.displayName) + ? Text(certificationAgencyAndLevel(selectedCertification!)) : Text(context.l10n.certifications_picker_hint), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -188,11 +188,17 @@ class CertificationPickerSheet extends ConsumerWidget { final isSelected = selectedCertification?.id == cert.id; final dateFormat = DateFormat.yMMMd(); + // Only non-null when a custom name owns the title, so the + // level is spoken exactly once either way. + final levelLabel = certificationSubtitle(cert) != null + ? ', ${certificationSubtitle(cert)}' + : ''; // Keep the agency: this label replaces the tile's own // semantics, including the subtitle that shows the agency // visually. The title is derived so it is not said twice. final certName = - '${cert.agency.displayName} ${certificationTitle(cert)}'; + '${cert.agency.displayName} ' + '${certificationTitle(cert)}$levelLabel'; final certLabel = cert.issueDate != null ? '$certName, issued ${dateFormat.format(cert.issueDate!)}${isSelected ? ', selected' : ''}${cert.isExpired ? ', expired' : ''}' : '$certName${isSelected ? ', selected' : ''}${cert.isExpired ? ', expired' : ''}'; @@ -214,8 +220,8 @@ class CertificationPickerSheet extends ConsumerWidget { title: Text(certificationTitle(cert)), subtitle: Text( cert.issueDate != null - ? '${cert.agency.displayName} - ${dateFormat.format(cert.issueDate!)}' - : cert.agency.displayName, + ? '${certificationAgencyAndLevel(cert)} - ${dateFormat.format(cert.issueDate!)}' + : certificationAgencyAndLevel(cert), ), trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) diff --git a/lib/features/certifications/presentation/widgets/certification_summary_widget.dart b/lib/features/certifications/presentation/widgets/certification_summary_widget.dart index df772aab8f..790ab0ae4f 100644 --- a/lib/features/certifications/presentation/widgets/certification_summary_widget.dart +++ b/lib/features/certifications/presentation/widgets/certification_summary_widget.dart @@ -222,7 +222,7 @@ class CertificationSummaryWidget extends ConsumerWidget { ), ), title: Text(certificationTitle(cert)), - subtitle: Text(cert.agency.displayName), + subtitle: Text(certificationAgencyAndLevel(cert)), trailing: const Icon(Icons.chevron_right), onTap: () { final state = GoRouterState.of(context); diff --git a/test/features/certifications/domain/certification_title_test.dart b/test/features/certifications/domain/certification_title_test.dart index 3c50c19f4a..da2b99d6a7 100644 --- a/test/features/certifications/domain/certification_title_test.dart +++ b/test/features/certifications/domain/certification_title_test.dart @@ -123,4 +123,28 @@ void main() { expect(certificationSubtitle(cert(name: 'Bali OW', level: null)), isNull); }); }); + + group('certificationAgencyAndLevel', () { + test('is the agency alone when the title already names the level', () { + expect( + certificationAgencyAndLevel(cert(name: 'PADI : Open Water')), + 'PADI', + ); + }); + + test('adds the level when a custom name owns the title', () { + // Issue #1265: without this the level appears nowhere on the tile. + expect( + certificationAgencyAndLevel(cert(name: 'Bill Ansell')), + 'PADI - Open Water', + ); + }); + + test('is the agency alone when a custom name has no level', () { + expect( + certificationAgencyAndLevel(cert(name: 'Bali OW', level: null)), + 'PADI', + ); + }); + }); } diff --git a/test/features/certifications/presentation/widgets/certification_list_content_test.dart b/test/features/certifications/presentation/widgets/certification_list_content_test.dart index a9a277a772..1ff8b1ec55 100644 --- a/test/features/certifications/presentation/widgets/certification_list_content_test.dart +++ b/test/features/certifications/presentation/widgets/certification_list_content_test.dart @@ -707,5 +707,98 @@ void main() { // carries "PADI" on its own, so the title must not repeat it. expect(find.text('Open Water'), findsWidgets); }); + + // A custom name takes the title, which leaves the level with nowhere to go + // unless the subtitle carries it. See issue #1265: a card entered as + // "Bill Ansell" / PADI / Divemaster showed no trace of "Divemaster". + testWidgets('a custom name keeps the certification in the subtitle', ( + tester, + ) async { + final overrides = await _buildPhoneOverrides( + certs: [ + _makeCert( + id: 'c1', + name: 'Bill Ansell', + level: CertificationLevel.diveMaster, + issueDate: DateTime(2026, 8, 24), + ), + ], + ); + + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: overrides, + child: const CertificationListContent(showAppBar: true), + ), + ); + await tester.pump(); + + expect(find.text('Bill Ansell'), findsOneWidget); + expect(find.text('PADI - Divemaster - Aug 24, 2026'), findsOneWidget); + }); + + testWidgets('a derived title does not repeat the level in the subtitle', ( + tester, + ) async { + final overrides = await _buildPhoneOverrides( + certs: [ + _makeCert( + id: 'c2', + name: '', + level: CertificationLevel.diveMaster, + issueDate: DateTime(2026, 8, 24), + ), + ], + ); + + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: overrides, + child: const CertificationListContent(showAppBar: true), + ), + ); + await tester.pump(); + + // The title already says "Divemaster"; the subtitle must not say it + // again, which is the duplication the title helper exists to remove. + expect(find.text('Divemaster'), findsOneWidget); + expect(find.text('PADI - Aug 24, 2026'), findsOneWidget); + }); + + testWidgets('accessibility label names the certification too', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + final overrides = await _buildPhoneOverrides( + certs: [ + _makeCert( + id: 'c3', + name: 'Bill Ansell', + level: CertificationLevel.diveMaster, + ), + ], + ); + + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: overrides, + child: const CertificationListContent(showAppBar: true), + ), + ); + await tester.pump(); + + // The label stands in for the whole tile, so a screen reader must hear + // the level even when a custom name owns the title. + expect( + find.bySemanticsLabel('PADI Bill Ansell, Divemaster'), + findsOneWidget, + ); + + handle.dispose(); + }); }); } From b4b9d1626cdeaa06641376d3bf77cc185b103c74 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 22:44:17 -0400 Subject: [PATCH 024/122] fix(certifications): address review on the title fix Cache certificationSubtitle in a local instead of calling it twice to build the screen-reader level label. It walks a candidate list and normalises each entry with a regex, so the second call was real repeated work as well as noise. Pin Intl.defaultLocale around the subtitle tests. DateFormat.yMMMd() resolves against that process global, not the MaterialApp.locale the harness passes, so the "Aug 24, 2026" assertions were riding on intl's implicit en_US fallback. Verified the sensitivity is real rather than theoretical: with the pin set to 'de' the assertion fails. Follows the precedent in trip_story_day_header_test.dart, which pins and restores without calling initializeDateFormatting because a widget test gets its symbol data from GlobalMaterialLocalizations. --- .../widgets/certification_list_content.dart | 5 ++--- .../widgets/certification_picker.dart | 5 ++--- .../widgets/certification_list_content_test.dart | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/features/certifications/presentation/widgets/certification_list_content.dart b/lib/features/certifications/presentation/widgets/certification_list_content.dart index aa9668c1d5..0b44259bfe 100644 --- a/lib/features/certifications/presentation/widgets/certification_list_content.dart +++ b/lib/features/certifications/presentation/widgets/certification_list_content.dart @@ -745,9 +745,8 @@ class CertificationListTile extends StatelessWidget { : ''; // Only non-null when a custom name owns the title, so the level is spoken // exactly once either way. - final levelLabel = certificationSubtitle(certification) != null - ? ', ${certificationSubtitle(certification)}' - : ''; + final level = certificationSubtitle(certification); + final levelLabel = level != null ? ', $level' : ''; return Semantics( // Keep the agency: this label stands in for the whole tile, so dropping diff --git a/lib/features/certifications/presentation/widgets/certification_picker.dart b/lib/features/certifications/presentation/widgets/certification_picker.dart index 6f96f0ed0f..0c3d43cc50 100644 --- a/lib/features/certifications/presentation/widgets/certification_picker.dart +++ b/lib/features/certifications/presentation/widgets/certification_picker.dart @@ -190,9 +190,8 @@ class CertificationPickerSheet extends ConsumerWidget { // Only non-null when a custom name owns the title, so the // level is spoken exactly once either way. - final levelLabel = certificationSubtitle(cert) != null - ? ', ${certificationSubtitle(cert)}' - : ''; + final level = certificationSubtitle(cert); + final levelLabel = level != null ? ', $level' : ''; // Keep the agency: this label replaces the tile's own // semantics, including the subtitle that shows the agency // visually. The title is derived so it is not said twice. diff --git a/test/features/certifications/presentation/widgets/certification_list_content_test.dart b/test/features/certifications/presentation/widgets/certification_list_content_test.dart index 1ff8b1ec55..f8f69e87f3 100644 --- a/test/features/certifications/presentation/widgets/certification_list_content_test.dart +++ b/test/features/certifications/presentation/widgets/certification_list_content_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; @@ -630,6 +631,20 @@ void main() { }); group('title derivation', () { + // The subtitle dates itself with DateFormat.yMMMd(), which resolves + // against Intl.defaultLocale (a process global that app.dart sets from the + // app locale), NOT the MaterialApp.locale the harness passes. Pin it so + // the "Aug 24, 2026" assertions below state their real dependency instead + // of riding on intl's implicit en_US fallback, and restore it so the + // global stays contained. No initializeDateFormatting is needed: these are + // widget tests, so GlobalMaterialLocalizations loads the symbol data. + String? previousLocale; + setUp(() { + previousLocale = Intl.defaultLocale; + Intl.defaultLocale = 'en'; + }); + tearDown(() => Intl.defaultLocale = previousLocale); + testWidgets('a cert with no stored name still shows a title', ( tester, ) async { From a11428ca6f1497ee2641396168b796e5f5c8397e Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 22:59:57 -0400 Subject: [PATCH 025/122] fix(3d): two-finger zoom, unobscured zoom buttons, closable settings sheet Issue #1188, reported on Android 1.7.5.6566. Pinch to zoom. Dive3dInteractiveViewport had two zoom paths and neither reached a touchscreen: a PanGestureRecognizer that only orbited, and a Listener on onPointerPanZoom* that only fires for PointerDeviceKind .trackpad. One scale recognizer now serves both touch gestures, branching on pointer count: one finger rotates and tilts, two fingers pinch-zoom and pan. The pinch is anchored to its focal point, so the terrain under the fingers stays under the fingers. supportedDevices gates pointer-down events only; isPointerPanZoomAllowed returns true unconditionally in the base class, so excluding trackpad there did nothing and every trackpad pan was applied twice (caught by the existing trackpad test). _TouchScaleGestureRecognizer refuses pan-zoom pointers so the Listener stays the single trackpad path. Legend clear of the zoom buttons. The seascape depth legend and the viewport's zoom column both hugged the right edge; on a 360x640 phone the legend covered the +/- buttons outright. The legend moves to the left edge in both seascape hosts. Closable terrain-appearance sheet. isScrollControlled removes the height ceiling entirely, and this content is taller than a phone, so the sheet reached y=0 and its drag handle landed inside Android's notification-shade swipe zone: dragging down opened the system shade, and nothing else could dismiss it. Adds useSafeArea, a max height of 85% of the screen so there is always scrim to tap, and a pinned header with the title and a Close button that stays put while the body scrolls. The viewInsets keyboard padding from issue #1094 wraps the whole column and is unchanged. --- .../presentation/pages/spatial_site_page.dart | 7 +- .../widgets/dive_3d_interactive_viewport.dart | 107 ++++++++++++++---- .../widgets/terrain_appearance_sheet.dart | 62 +++++++++- .../presentation/site_terrain_pane.dart | 7 +- .../dive_3d_interactive_viewport_test.dart | 73 ++++++++++++ .../terrain_appearance_sheet_test.dart | 42 +++++++ .../presentation/site_terrain_pane_test.dart | 24 ++++ 7 files changed, 293 insertions(+), 29 deletions(-) diff --git a/lib/features/dive_3d/presentation/pages/spatial_site_page.dart b/lib/features/dive_3d/presentation/pages/spatial_site_page.dart index 508330ba2f..b2dfe35780 100644 --- a/lib/features/dive_3d/presentation/pages/spatial_site_page.dart +++ b/lib/features/dive_3d/presentation/pages/spatial_site_page.dart @@ -155,13 +155,16 @@ class _SpatialSitePageState extends ConsumerState child: _captions(result!), ), // The legend describes the depth ramp; a photographed - // surface has no ramp to explain. + // surface has no ramp to explain. It sits LEFT because the + // viewport's zoom column owns the right edge, and on a + // phone-sized pane a right-hand legend covers the +/- + // buttons outright (issue #1188). if (result.grid != null && result.axisInputs != null && appearance.surfaceMode != SeascapeSurfaceMode.imagery) Positioned( top: 40, - right: 8, + left: 8, child: SeascapeDepthLegend( maxDepthMeters: result.axisInputs!.maxDepth, hasLand: result.grid!.depthsMeters.any( diff --git a/lib/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport.dart b/lib/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport.dart index 9edc61e4d9..dd5f5c9085 100644 --- a/lib/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport.dart +++ b/lib/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport.dart @@ -93,6 +93,8 @@ class Dive3dInteractiveViewport extends StatefulWidget { class _Dive3dInteractiveViewportState extends State { static const double _initialYaw = -32; static const double _initialPitch = 22; + static const double _minZoom = 0.4; + static const double _maxZoom = 8.0; double _yaw = _initialYaw; double _pitch = _initialPitch; double _zoom = 1.0; @@ -100,6 +102,9 @@ class _Dive3dInteractiveViewportState extends State { // as a Transform on the painted output; picks subtract it from the cursor. Offset _pan = Offset.zero; double _panZoomBaseZoom = 1.0; + // Zoom at the moment the active touch pinch began; ScaleUpdateDetails.scale + // is cumulative against the gesture start, not the previous tick. + double _scaleGestureBaseZoom = 1.0; // Last laid-out size, captured in build so camera-change handlers (which lack // the LayoutBuilder constraints) can re-project the hover pick. Size? _lastLayoutSize; @@ -133,24 +138,68 @@ class _Dive3dInteractiveViewportState extends State { } } - void _onPanUpdate(DragUpdateDetails details) { + void _onScaleStart(ScaleStartDetails _) { + _scaleGestureBaseZoom = _zoom; + } + + /// One recognizer serves both touch gestures, because Flutter cannot run a + /// pan and a scale recognizer in the same arena without one starving the + /// other. Pointer count decides the meaning: one finger orbits (or pans the + /// locked plan view in chart mode), two fingers pinch-zoom and pan. That is + /// the mapping issue #1188 asked for, and it is the only zoom a touchscreen + /// can reach -- pan/zoom pointer events are trackpad-only. + void _onScaleUpdate(Size size, ScaleUpdateDetails details) { + final delta = details.focalPointDelta; + if (details.pointerCount < 2) { + setState(() { + if (widget.chartMode) { + _pan += delta; + } else { + // Drag follows the object: dragging right spins it clockwise (yaw + // up), dragging down tilts it toward the viewer. + _yaw += delta.dx * 0.4; + _pitch = (_pitch + delta.dy * 0.4).clamp(-80.0, 80.0); + } + }); + _refreshHoverAfterCameraChange(); + return; + } setState(() { - if (widget.chartMode) { - // Chart mode is a locked plan view: one-finger drag pans the map. - _pan += details.delta; - } else { - // Drag follows the object: dragging right spins it clockwise (yaw - // up), dragging down tilts it toward the viewer. - _yaw += details.delta.dx * 0.4; - _pitch = (_pitch + details.delta.dy * 0.4).clamp(-80.0, 80.0); - } + _setZoomAnchored( + size, + (_scaleGestureBaseZoom * details.scale).clamp(_minZoom, _maxZoom), + focalPoint: details.localFocalPoint, + focalDelta: delta, + ); }); _refreshHoverAfterCameraChange(); } + /// Scales the camera to [next] while keeping the scene point that sat under + /// the pinch's PREVIOUS focal point ([focalPoint] - [focalDelta]) welded to + /// the fingers, then carries the focal point's own travel as a pan. + /// + /// [SceneProjector] scales the scene about the canvas center, and the pan + /// Transform is applied on top, so a projected point lands at + /// `center + zoom * v + pan`. Solving that for the pan that pins one point + /// across a zoom change gives the single expression below; with + /// `next == _zoom` it degenerates to a plain `_pan += focalDelta`. + void _setZoomAnchored( + Size size, + double next, { + required Offset focalPoint, + Offset focalDelta = Offset.zero, + }) { + final ratio = next / _zoom; + final center = Offset(size.width / 2, size.height / 2); + _pan = + focalPoint - center - (focalPoint - focalDelta - center - _pan) * ratio; + _zoom = next; + } + void _zoomBy(double factor) { setState(() { - _zoom = (_zoom * factor).clamp(0.4, 8.0); + _zoom = (_zoom * factor).clamp(_minZoom, _maxZoom); }); _refreshHoverAfterCameraChange(); } @@ -164,7 +213,7 @@ class _Dive3dInteractiveViewportState extends State { void _onPanZoomUpdate(PointerPanZoomUpdateEvent event) { setState(() { _pan += event.panDelta; - _zoom = (_panZoomBaseZoom * event.scale).clamp(0.4, 8.0); + _zoom = (_panZoomBaseZoom * event.scale).clamp(_minZoom, _maxZoom); }); _refreshHoverAfterCameraChange(); } @@ -413,12 +462,14 @@ class _Dive3dInteractiveViewportState extends State { final gestures = RawGestureDetector( behavior: HitTestBehavior.opaque, gestures: { - // Rotate: one-finger drag from mouse/touch/stylus. Trackpad - // two-finger pans are handled as pan by the Listener below, so we - // exclude trackpad here to avoid rotating while panning. - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer( + // Rotate (one finger) and pinch-zoom + pan (two fingers) from + // mouse/touch/stylus. Trackpad pan-zoom pointers stay with the + // Listener below. + _TouchScaleGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + _TouchScaleGestureRecognizer + >( + () => _TouchScaleGestureRecognizer( supportedDevices: const { PointerDeviceKind.touch, PointerDeviceKind.mouse, @@ -427,7 +478,9 @@ class _Dive3dInteractiveViewportState extends State { PointerDeviceKind.unknown, }, ), - (r) => r.onUpdate = _onPanUpdate, + (r) => r + ..onStart = _onScaleStart + ..onUpdate = (details) => _onScaleUpdate(size, details), ), TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( @@ -488,6 +541,7 @@ class _Dive3dInteractiveViewportState extends State { Widget _zoomControls(BuildContext context) { return Column( + key: const ValueKey('dive3dZoomControls'), mainAxisSize: MainAxisSize.min, children: [ _zoomButton( @@ -537,6 +591,21 @@ class _Dive3dInteractiveViewportState extends State { } } +/// A scale recognizer that ignores trackpad pan/zoom pointers. +/// +/// `supportedDevices` filters pointer-DOWN events only: +/// [GestureRecognizer.isPointerPanZoomAllowed] returns true unconditionally, +/// so a trackpad gesture reaches every recognizer no matter what devices it +/// declares. The viewport handles trackpads on a [Listener], and without this +/// refusal both paths would fire and every trackpad pan would move the camera +/// twice. +class _TouchScaleGestureRecognizer extends ScaleGestureRecognizer { + _TouchScaleGestureRecognizer({super.supportedDevices}); + + @override + bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) => false; +} + /// Foreground layer: only the scrub cursor. Repaints on every scrub tick /// (via [scrubPosition] as the repaint listenable) without touching the /// depth-sorted scene beneath it. Placed via the scene's ScrubPath. diff --git a/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart b/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart index c9052507a6..572ee8da3b 100644 --- a/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart +++ b/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart @@ -14,7 +14,18 @@ void showTerrainAppearanceSheet(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, + // A scroll-controlled sheet grows to whatever its content asks for, and + // this content is taller than a phone. Left alone it reached y=0, putting + // the drag handle inside Android's notification-shade swipe zone -- so + // pulling the sheet down opened the system shade instead of closing it, + // and the sheet could not be dismissed at all (issue #1188). The safe + // area keeps it clear of the status bar; the height cap leaves a strip of + // scrim above it so tapping outside stays an obvious way out. + useSafeArea: true, showDragHandle: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * _maxHeightFraction, + ), // The custom-level rows open a keypad. Without a viewInsets pad the sheet // keeps its full height, so the lower rows and the add button sit behind // the keyboard with no scroll extent left to bring them up (issue #1094). @@ -24,12 +35,56 @@ void showTerrainAppearanceSheet(BuildContext context) { padding: EdgeInsets.only( bottom: MediaQuery.of(sheetContext).viewInsets.bottom, ), - child: const SingleChildScrollView(child: TerrainAppearanceSheet()), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _SheetHeader(onClose: () => Navigator.of(sheetContext).pop()), + // Loose fit: the sheet still hugs short content, but a body + // taller than the cap scrolls under the pinned header instead of + // pushing it (and the close action) off the screen. + const Flexible( + child: SingleChildScrollView(child: TerrainAppearanceSheet()), + ), + ], + ), ), ), ); } +/// Share of the screen the sheet may occupy at most. +const double _maxHeightFraction = 0.85; + +/// Title plus an explicit way out. The drag handle alone is not enough on +/// Android, where a downward drag near the top edge belongs to the system. +class _SheetHeader extends StatelessWidget { + final VoidCallback onClose; + + const _SheetHeader({required this.onClose}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.dive3d_seascape_appearance, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton( + key: const ValueKey('terrainAppearanceCloseButton'), + onPressed: onClose, + child: Text(context.l10n.common_action_close), + ), + ], + ), + ); + } +} + /// Issue #1065 knobs: ramp depth range, banded gradient, contour mode with /// a custom level editor, line thickness, steep-wall angle. Every change /// writes straight through SettingsNotifier (device-local persistence), @@ -82,11 +137,6 @@ class TerrainAppearanceSheet extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l10n.dive3d_seascape_appearance, - style: Theme.of(context).textTheme.titleMedium, - ), - _controlGap, Text(l10n.dive3d_seascape_appearance_surface), _labelGap, SegmentedButton( diff --git a/lib/features/site_scape/presentation/site_terrain_pane.dart b/lib/features/site_scape/presentation/site_terrain_pane.dart index 16417f66b5..ea5aee41aa 100644 --- a/lib/features/site_scape/presentation/site_terrain_pane.dart +++ b/lib/features/site_scape/presentation/site_terrain_pane.dart @@ -201,11 +201,14 @@ class _SiteTerrainPaneState extends ConsumerState { child: _sourceChip(sourceId, resolutionMeters), ), // The legend describes the depth ramp; a photographed - // surface has no ramp to explain. + // surface has no ramp to explain. It sits LEFT because the + // viewport's zoom column owns the right edge, and on a + // phone-sized pane a right-hand legend covers the +/- + // buttons outright (issue #1188). if (appearance.surfaceMode != SeascapeSurfaceMode.imagery) Positioned( top: 96, - right: 8, + left: 8, child: SeascapeDepthLegend( maxDepthMeters: axisInputs.maxDepth, hasLand: grid.depthsMeters.any( diff --git a/test/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport_test.dart b/test/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport_test.dart index 853d9cfa29..f844a7653f 100644 --- a/test/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport_test.dart +++ b/test/features/dive_3d/presentation/widgets/dive_3d_interactive_viewport_test.dart @@ -267,6 +267,79 @@ void main() { expect(scenePainterOf(tester).zoom, 1.0); }); + // Issue #1188: on a touchscreen there are no pan/zoom pointers at all, so + // the trackpad path below can never fire. Two fingers must pinch-zoom and + // pan, while one finger keeps orbiting. + testWidgets('two-finger pinch zooms about the focal point', (tester) async { + await pumpViewport(tester, scene: buildScene()); + expect(scenePainterOf(tester).zoom, 1.0); + final before = scenePainterOf(tester); + final center = tester.getCenter(find.byType(Dive3dInteractiveViewport)); + + final f1 = await tester.startGesture(center - const Offset(20, 0)); + final f2 = await tester.startGesture(center + const Offset(20, 0)); + await tester.pump(); + for (var i = 0; i < 4; i++) { + await f1.moveBy(const Offset(-15, 0)); + await f2.moveBy(const Offset(15, 0)); + await tester.pump(); + } + + final after = scenePainterOf(tester); + expect(after.zoom, greaterThan(1.0)); + // A pinch must not double as a rotation. + expect(after.yawDegrees, before.yawDegrees); + expect(after.pitchDegrees, before.pitchDegrees); + + await f1.up(); + await f2.up(); + // Let the double-tap recognizer's countdown expire before teardown. + await tester.pump(const Duration(milliseconds: 500)); + }); + + testWidgets('two fingers moving together pan without rotating', ( + tester, + ) async { + await pumpViewport(tester, scene: buildScene()); + Offset panOffset() { + final t = tester + .widget(find.byKey(const ValueKey('dive3dViewportPan'))) + .transform + .getTranslation(); + return Offset(t.x, t.y); + } + + final before = scenePainterOf(tester); + final center = tester.getCenter(find.byType(Dive3dInteractiveViewport)); + final f1 = await tester.startGesture(center - const Offset(20, 0)); + final f2 = await tester.startGesture(center + const Offset(20, 0)); + await tester.pump(); + for (var i = 0; i < 3; i++) { + await f1.moveBy(const Offset(10, 6)); + await f2.moveBy(const Offset(10, 6)); + await tester.pump(); + } + + expect(panOffset().dx, greaterThan(0)); + expect(panOffset().dy, greaterThan(0)); + final after = scenePainterOf(tester); + expect(after.yawDegrees, before.yawDegrees); + expect(after.pitchDegrees, before.pitchDegrees); + expect(after.zoom, closeTo(1.0, 0.05)); + + await f1.up(); + await f2.up(); + // Let the double-tap recognizer's countdown expire before teardown. + await tester.pump(const Duration(milliseconds: 500)); + }); + + testWidgets('zoom controls carry a stable key for host layout checks', ( + tester, + ) async { + await pumpViewport(tester, scene: buildScene()); + expect(find.byKey(const ValueKey('dive3dZoomControls')), findsOneWidget); + }); + testWidgets('trackpad pan translates the view and pinch zooms', ( tester, ) async { diff --git a/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart b/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart index 5957b51a94..7d10ee42ec 100644 --- a/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart +++ b/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart @@ -114,6 +114,48 @@ void main() { return container; } + // Issue #1188: the scroll-controlled sheet grew until it touched the top + // edge of the screen. Its drag handle then sat inside Android's + // notification-shade swipe zone, and with no close action the sheet became + // impossible to dismiss. + testWidgets('the sheet stops short of the top edge on a phone', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(360, 560)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await pumpSheetRoute(tester); + final sheet = tester.getRect(find.byType(BottomSheet)); + expect(sheet.top, greaterThan(0)); + }); + + testWidgets('the Close action dismisses the sheet', (tester) async { + await pumpSheetRoute(tester); + expect(find.byType(TerrainAppearanceSheet), findsOneWidget); + await tester.tap( + find.byKey(const ValueKey('terrainAppearanceCloseButton')), + ); + await tester.pumpAndSettle(); + expect(find.byType(TerrainAppearanceSheet), findsNothing); + }); + + testWidgets('the Close action stays reachable when the body scrolls', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(360, 560)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await pumpSheetRoute(tester); + final closeButton = find.byKey( + const ValueKey('terrainAppearanceCloseButton'), + ); + final before = tester.getRect(closeButton); + await tester.drag( + find.byKey(const ValueKey('seascapeBandedSwitch')), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(closeButton), before); + }); + testWidgets('banded switch writes through to settings', (tester) async { final container = await pumpSheet(tester); expect( diff --git a/test/features/site_scape/presentation/site_terrain_pane_test.dart b/test/features/site_scape/presentation/site_terrain_pane_test.dart index 46cdaf0c5d..abdb56558c 100644 --- a/test/features/site_scape/presentation/site_terrain_pane_test.dart +++ b/test/features/site_scape/presentation/site_terrain_pane_test.dart @@ -182,6 +182,30 @@ void main() { ); }); + // Issue #1188: on a phone-sized pane the legend and the viewport's zoom + // column both hugged the right edge, and the legend covered the +/- + // buttons outright. + testWidgets('the legend never overlaps the zoom controls', (tester) async { + for (final surface in const [Size(360, 640), Size(360, 380)]) { + await tester.binding.setSurfaceSize(surface); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(page(readyState())); + await tester.pump(); + await tester.pump(); + final legend = tester.getRect( + find.byKey(const ValueKey('seascapeDepthLegend')), + ); + final zoom = tester.getRect( + find.byKey(const ValueKey('dive3dZoomControls')), + ); + expect( + legend.overlaps(zoom), + isFalse, + reason: 'legend $legend overlaps zoom controls $zoom at $surface', + ); + } + }); + testWidgets('imagery reaches the viewport and shows attribution', ( tester, ) async { From 379a2b2cb245e1b4225e54e91311a3c9dfab4d7a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:09:12 -0400 Subject: [PATCH 026/122] fix(dc-import): scan for a saved computer's address before connecting (#1232) Downloading from a saved Bluetooth dive computer synthesized a device from the stored address and connected to it directly, with no scan. On Android (connectGatt on an unbonded device the stack has not seen advertise) and Windows (FromBluetoothAddressAsync right after stopping the advertisement watcher) that connect fails instantly with connect_failed, while the scan-and-download flow for the very same computer works. macOS and iOS were unaffected because the native BlePeripheralResolver re-scans before connecting. The saved-computer download step now runs DiscoveryNotifier.scanForAddress for up to 15 seconds and downloads from the freshly advertised device, which also carries the real descriptor instead of a guessed one. When the scan does not see the address it falls back to the stored address exactly as before. A device left selected by an earlier discovery session is only used when its address matches the saved one. --- .../providers/discovery_providers.dart | 58 ++++ .../widgets/dc_adapter_steps.dart | 101 ++++++- .../discovery_scan_for_address_test.dart | 138 +++++++++ ...dapter_download_step_cutoff_race_test.dart | 18 +- ...adapter_download_step_force_full_test.dart | 11 + ..._adapter_download_step_reacquire_test.dart | 279 ++++++++++++++++++ 6 files changed, 597 insertions(+), 8 deletions(-) create mode 100644 test/features/dive_computer/presentation/providers/discovery_scan_for_address_test.dart create mode 100644 test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart diff --git a/lib/features/dive_computer/presentation/providers/discovery_providers.dart b/lib/features/dive_computer/presentation/providers/discovery_providers.dart index 07c89bce98..3a4f747269 100644 --- a/lib/features/dive_computer/presentation/providers/discovery_providers.dart +++ b/lib/features/dive_computer/presentation/providers/discovery_providers.dart @@ -251,6 +251,57 @@ class DiscoveryNotifier extends StateNotifier { state = state.copyWith(isScanning: false); } + /// Scans until a device advertising [address] is seen, then stops. + /// + /// Resolves with the freshly discovered device, or with null when the scan + /// could not start, native discovery ended, or [timeout] elapsed without + /// seeing the address. The scan is stopped on every path. + /// + /// The saved-computer download path uses this to re-acquire the device + /// before connecting. A direct connect to a stored address that the + /// Bluetooth stack has not seen advertise recently fails on Android and + /// Windows, while the very same address connects fine right after a scan + /// (issue #1232). macOS and iOS re-scan natively before connecting; this + /// gives the other platforms the same behaviour. + Future scanForAddress( + String address, { + required Duration timeout, + }) async { + final completer = Completer(); + + void check(DiscoveryState current) { + if (completer.isCompleted) return; + final match = current.discoveredDevices + .where((d) => bluetoothAddressesMatch(d.address, address)) + .firstOrNull; + if (match != null) { + completer.complete(match); + } else if (!current.isScanning) { + completer.complete(null); + } + } + + // startScan settles isScanning before returning: true once discovery is + // running, false (with an error message) when it could not start. + await startScan(); + final subscription = stream.listen(check); + check(state); + final timer = Timer(timeout, () { + if (!completer.isCompleted) completer.complete(null); + }); + + try { + return await completer.future; + } finally { + timer.cancel(); + // Not awaited: a broadcast subscription's cancel() resolves to the + // root-zone null future, which a fake-async widget test can never + // flush. Cancellation itself is synchronous. + unawaited(subscription.cancel()); + await stopScan(); + } + } + /// Select a device and move to the next step. void selectDevice(DiscoveredDevice device) { state = state.copyWith( @@ -310,6 +361,13 @@ class DiscoveryNotifier extends StateNotifier { } } +/// Whether two transport addresses name the same device. +/// +/// Android reports colon-separated MACs and Windows colon-free hex; both are +/// stable per platform, so only letter case is normalized. +bool bluetoothAddressesMatch(String a, String b) => + a.toUpperCase() == b.toUpperCase(); + /// Provider for the discovery notifier. final discoveryNotifierProvider = StateNotifierProvider((ref) { diff --git a/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart b/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart index 7ad8a0d767..940f25f047 100644 --- a/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart +++ b/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart @@ -278,6 +278,11 @@ class DcAdapterDownloadStep extends ConsumerStatefulWidget { final DiveComputerAdapter adapter; final DiveComputer? knownComputer; + /// How long a saved-computer download scans for the computer's stored + /// address before falling back to a direct connect with that address. + /// Matches the first resolve attempt of the macOS/iOS native resolver. + static const knownDeviceScanTimeout = Duration(seconds: 15); + @override ConsumerState createState() => _DcAdapterDownloadStepState(); @@ -286,19 +291,62 @@ class DcAdapterDownloadStep extends ConsumerStatefulWidget { class _DcAdapterDownloadStepState extends ConsumerState { bool _captured = false; bool _computerResolved = false; + bool _searchingForKnownDevice = false; bool _noDives = false; @override void initState() { super.initState(); - // In discovery mode, check if the device matches a known computer - // BEFORE the download starts. If found, the computer's fingerprint - // enables incremental download (only new dives). - if (widget.knownComputer != null) { - _computerResolved = true; - } else { + final computer = widget.knownComputer; + if (computer == null) { + // In discovery mode, check if the device matches a known computer + // BEFORE the download starts. If found, the computer's fingerprint + // enables incremental download (only new dives). WidgetsBinding.instance.addPostFrameCallback((_) => _resolveComputer()); + } else { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _reacquireKnownDevice(computer), + ); + } + } + + /// Re-acquires a saved Bluetooth computer by scanning for its stored + /// address before the download connects (issue #1232). + /// + /// Connecting straight to a stored address fails on Android and Windows + /// unless the stack has recently seen the device advertise, which is why + /// the scan-and-download flow worked for the same computer while the + /// saved entry did not. If the scan does not see the address, the step + /// falls back to the stored address exactly as before. + Future _reacquireKnownDevice(DiveComputer computer) async { + if (!mounted) return; + final address = computer.bluetoothAddress; + final selected = ref.read(discoveryNotifierProvider).selectedDevice; + final alreadyAcquired = + selected != null && + address != null && + bluetoothAddressesMatch(selected.address, address); + final isBluetooth = + _connectionTypeFromString(computer.connectionType) == + DeviceConnectionType.ble; + + if (address == null || !isBluetooth || alreadyAcquired) { + setState(() => _computerResolved = true); + return; } + + setState(() => _searchingForKnownDevice = true); + final notifier = ref.read(discoveryNotifierProvider.notifier); + final device = await notifier.scanForAddress( + address, + timeout: DcAdapterDownloadStep.knownDeviceScanTimeout, + ); + if (device != null) notifier.selectDevice(device); + if (!mounted) return; + setState(() { + _searchingForKnownDevice = false; + _computerResolved = true; + }); } Future _resolveComputer() async { @@ -330,6 +378,10 @@ class _DcAdapterDownloadStepState extends ConsumerState { // Wait for computer resolution before creating the download widget. // This ensures the fingerprint is available for incremental download. if (!_computerResolved) { + final knownComputer = widget.knownComputer; + if (_searchingForKnownDevice && knownComputer != null) { + return _KnownDeviceSearchView(computer: knownComputer); + } return const Center(child: CircularProgressIndicator()); } @@ -337,6 +389,16 @@ class _DcAdapterDownloadStepState extends ConsumerState { var device = discoveryState.selectedDevice; final computer = widget.knownComputer ?? widget.adapter.computer; + // A saved computer downloads only from a device carrying its stored + // address: a device left selected by an earlier discovery session must + // not be used in its place. + final storedAddress = widget.knownComputer?.bluetoothAddress; + if (device != null && + storedAddress != null && + !bluetoothAddressesMatch(device.address, storedAddress)) { + device = null; + } + // For known-computer downloads, synthesize a DiscoveredDevice from the // computer's stored connection info when discovery state has no device. // The device descriptor lookup provides the dcModel integer that @@ -483,6 +545,33 @@ DeviceConnectionType _connectionTypeFromString(String? type) { } } +// --------------------------------------------------------------------------- +// Searching for a saved computer +// --------------------------------------------------------------------------- + +class _KnownDeviceSearchView extends StatelessWidget { + const _KnownDeviceSearchView({required this.computer}); + + final DiveComputer computer; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + l10n.diveComputer_download_searchingForDevice(computer.displayName), + ), + ], + ), + ); + } +} + // --------------------------------------------------------------------------- // No new dives view // --------------------------------------------------------------------------- diff --git a/test/features/dive_computer/presentation/providers/discovery_scan_for_address_test.dart b/test/features/dive_computer/presentation/providers/discovery_scan_for_address_test.dart new file mode 100644 index 0000000000..be1e781929 --- /dev/null +++ b/test/features/dive_computer/presentation/providers/discovery_scan_for_address_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/features/dive_computer/presentation/providers/discovery_providers.dart'; + +/// Host API stub that records discovery calls without touching a platform +/// channel. +class _FakeHostApi extends pigeon.DiveComputerHostApi { + int startDiscoveryCalls = 0; + int stopDiscoveryCalls = 0; + Object? startDiscoveryError; + + @override + Future startDiscovery(pigeon.TransportType transport) async { + startDiscoveryCalls++; + final error = startDiscoveryError; + if (error != null) throw error; + } + + @override + Future stopDiscovery() async { + stopDiscoveryCalls++; + } +} + +const _savedAddress = 'E8:F8:BE:96:61:57'; + +pigeon.DiscoveredDevice _advert(String address) => pigeon.DiscoveredDevice( + vendor: 'Shearwater', + product: 'Petrel 3', + model: 10, + address: address, + name: 'Petrel 3', + transport: pigeon.TransportType.ble, +); + +void main() { + // Issue #1232: a download started from a saved computer connected straight + // to the stored address with no scan, which fails on Android and Windows + // when the stack has not seen the device advertise recently. This is the + // scan-then-resolve primitive the saved-computer path uses to re-acquire + // the device first. + group('DiscoveryNotifier.scanForAddress', () { + late _FakeHostApi hostApi; + late pigeon.DiveComputerService service; + late DiscoveryNotifier notifier; + + setUp(() { + hostApi = _FakeHostApi(); + service = pigeon.DiveComputerService(hostApi: hostApi); + notifier = DiscoveryNotifier( + service: service, + requiresRuntimePermissions: false, + ); + }); + + tearDown(() { + notifier.dispose(); + }); + + test( + 'resolves the device advertising the saved address and stops the scan', + () async { + final pending = notifier.scanForAddress( + _savedAddress, + timeout: const Duration(seconds: 5), + ); + await pumpEventQueue(); + expect(hostApi.startDiscoveryCalls, 1); + + service.onDeviceDiscovered(_advert('11:22:33:44:55:66')); + service.onDeviceDiscovered(_advert(_savedAddress)); + + final device = await pending; + expect(device, isNotNull); + expect(device!.address, _savedAddress); + expect(device.recognizedModel?.manufacturer, 'Shearwater'); + expect(device.recognizedModel?.model, 'Petrel 3'); + expect(hostApi.stopDiscoveryCalls, 1); + expect(notifier.state.isScanning, isFalse); + }, + ); + + test('matches the saved address regardless of letter case', () async { + final pending = notifier.scanForAddress( + _savedAddress.toLowerCase(), + timeout: const Duration(seconds: 5), + ); + await pumpEventQueue(); + + service.onDeviceDiscovered(_advert(_savedAddress)); + + final device = await pending; + expect(device?.address, _savedAddress); + }); + + test('returns null and stops the scan when the address is not seen ' + 'before the timeout', () async { + final pending = notifier.scanForAddress( + _savedAddress, + timeout: const Duration(milliseconds: 50), + ); + await pumpEventQueue(); + service.onDeviceDiscovered(_advert('11:22:33:44:55:66')); + + final device = await pending; + expect(device, isNull); + expect(hostApi.stopDiscoveryCalls, 1); + expect(notifier.state.isScanning, isFalse); + }); + + test('returns null without waiting when the scan cannot start', () async { + hostApi.startDiscoveryError = StateError('adapter unavailable'); + + // A generous timeout: the call must give up on the start failure, + // not sit out the timeout. + final device = await notifier + .scanForAddress(_savedAddress, timeout: const Duration(minutes: 5)) + .timeout(const Duration(seconds: 2)); + + expect(device, isNull); + expect(notifier.state.errorMessage, isNotNull); + }); + + test( + 'returns null when native discovery completes without the device', + () async { + final pending = notifier + .scanForAddress(_savedAddress, timeout: const Duration(minutes: 5)) + .timeout(const Duration(seconds: 2)); + await pumpEventQueue(); + + service.onDiscoveryComplete(); + + expect(await pending, isNull); + }, + ); + }); +} diff --git a/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_cutoff_race_test.dart b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_cutoff_race_test.dart index c5dc36ad9d..a46585807f 100644 --- a/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_cutoff_race_test.dart +++ b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_cutoff_race_test.dart @@ -95,11 +95,20 @@ void main() { // for a selected device) constructs a DiscoveryNotifier that subscribes // to this stream immediately. when(mockService.discoveryComplete).thenAnswer((_) => const Stream.empty()); + // The step scans for the computer's stored address before connecting + // (issue #1232). Nothing is ever reported, so each test advances past + // the scan timeout to reach the synthesized-device fallback. + when(mockService.discoveredDevices).thenAnswer((_) => const Stream.empty()); + when(mockService.startDiscovery(any)).thenAnswer((_) async {}); + when(mockService.stopDiscovery()).thenAnswer((_) async {}); when( mockService.startDownload(any, fingerprint: anyNamed('fingerprint')), ).thenAnswer((_) async {}); }); + final pastScanTimeout = + DcAdapterDownloadStep.knownDeviceScanTimeout + const Duration(seconds: 1); + testWidgets( 'late-arriving cutoff default does not crash and shows the prompt ' 'instead of silently auto-starting', @@ -117,8 +126,11 @@ void main() { cutoffCompleter: cutoffCompleter, ), ); - // Let _computerResolved and deviceDescriptorsProvider settle. The - // cutoff provider is still in-flight at this point. + // Let the saved-address scan time out, then _computerResolved and + // deviceDescriptorsProvider settle. The cutoff provider is still + // in-flight at this point. + await tester.pump(); + await tester.pump(pastScanTimeout); await tester.pump(); await tester.pump(); @@ -163,6 +175,8 @@ void main() { ), ); await tester.pump(); + await tester.pump(pastScanTimeout); + await tester.pump(); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); diff --git a/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_force_full_test.dart b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_force_full_test.dart index a8b263626f..f84ba1dcb2 100644 --- a/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_force_full_test.dart +++ b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_force_full_test.dart @@ -65,6 +65,9 @@ Widget _buildDownloadStep({ // reset-then-apply-then-start ordering is verified in // `test/features/dive_computer/presentation/widgets/download_step_widget_force_full_test.dart`. +final _pastScanTimeout = + DcAdapterDownloadStep.knownDeviceScanTimeout + const Duration(seconds: 1); + void main() { testWidgets( 'adapter forceFullDownload=true propagates to DownloadStepWidget', @@ -87,6 +90,11 @@ void main() { knownComputer: computer, ), ); + // The step first scans for the computer's stored address (issue + // #1232); the fake service never reports a device, so advance past + // the scan timeout to reach the synthesized-device fallback. + await tester.pump(); + await tester.pump(_pastScanTimeout); // Only one async gate applies here: deviceDescriptorsProvider // (synthesizing a device from the known computer). With // forceFullDownload=true, `promptCouldApply` in DcAdapterDownloadStep @@ -123,6 +131,9 @@ void main() { knownComputer: computer, ), ); + // Advance past the saved-address scan (see the first test). + await tester.pump(); + await tester.pump(_pastScanTimeout); // Two async gates settle in sequence before DownloadStepWidget is // constructed: deviceDescriptorsProvider (synthesizing a device from // the known computer), then firstSyncCutoffDefaultProvider (only diff --git a/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart new file mode 100644 index 0000000000..114141f7ac --- /dev/null +++ b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/features/dive_computer/domain/entities/device_model.dart'; +import 'package:submersion/features/dive_computer/presentation/providers/discovery_providers.dart'; +import 'package:submersion/features/dive_computer/presentation/providers/download_providers.dart'; +import 'package:submersion/features/dive_computer/presentation/widgets/download_step_widget.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; +import 'package:submersion/features/import_wizard/data/adapters/dive_computer_adapter.dart'; +import 'package:submersion/features/import_wizard/presentation/widgets/dc_adapter_steps.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/fake_import_adapter_deps.dart'; + +// --------------------------------------------------------------------------- +// Fake host API: records the order of native calls and what startDownload +// received, without touching a platform channel. +// --------------------------------------------------------------------------- + +class _RecordingHostApi extends pigeon.DiveComputerHostApi { + final List calls = []; + final List downloads = []; + + @override + Future startDiscovery(pigeon.TransportType transport) async { + calls.add('startDiscovery'); + } + + @override + Future stopDiscovery() async { + calls.add('stopDiscovery'); + } + + @override + Future startDownload( + pigeon.DiscoveredDevice device, + String? fingerprint, + ) async { + calls.add('startDownload'); + downloads.add(device); + } + + @override + Future> getDeviceDescriptors() async => []; + + @override + Future getLibdivecomputerVersion() async => '0.0.0'; +} + +/// Discovery notifier whose initial state is chosen by the test. +class _SeededDiscoveryNotifier extends DiscoveryNotifier { + _SeededDiscoveryNotifier({ + required super.service, + required DiscoveryState seed, + }) : super(requiresRuntimePermissions: false) { + state = seed; + } +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const _savedAddress = 'E8:F8:BE:96:61:57'; + +DiveComputer _savedComputer({ + String connectionType = 'bluetooth', + String bluetoothAddress = _savedAddress, +}) { + final now = DateTime(2026, 8, 23); + return DiveComputer( + id: 'dc-1', + diverId: 'diver-1', + name: 'My Petrel', + manufacturer: 'Shearwater', + model: 'Petrel 3', + connectionType: connectionType, + bluetoothAddress: bluetoothAddress, + lastDiveFingerprint: 'abc', + createdAt: now, + updatedAt: now, + ); +} + +pigeon.DiscoveredDevice _advert(String address) => pigeon.DiscoveredDevice( + vendor: 'Shearwater', + product: 'Petrel 3', + model: 10, + address: address, + name: 'Petrel 3', + transport: pigeon.TransportType.ble, +); + +DiscoveredDevice _discovered(String address) => DiscoveredDevice( + id: 'seeded', + name: 'Petrel 3', + connectionType: DeviceConnectionType.ble, + address: address, + recognizedModel: const DeviceModel( + id: 'shearwater_petrel3', + manufacturer: 'Shearwater', + model: 'Petrel 3', + connectionTypes: [DeviceConnectionType.ble], + dcModel: 10, + ), + discoveredAt: DateTime(2026, 8, 23), +); + +class _Harness { + _Harness({DiscoveryState seed = const DiscoveryState()}) + : hostApi = _RecordingHostApi(), + _seed = seed { + service = pigeon.DiveComputerService(hostApi: hostApi); + } + + final _RecordingHostApi hostApi; + final DiscoveryState _seed; + final FakeImportAdapterDeps deps = FakeImportAdapterDeps(); + late final pigeon.DiveComputerService service; + + Widget build(DiveComputer computer) { + final adapter = DiveComputerAdapter( + importService: deps.importService, + computerRepository: deps.computerRepo, + diveRepository: deps.diveRepo, + consolidationService: deps.consolidationService, + diverId: 'diver-1', + knownComputer: computer, + ); + return ProviderScope( + overrides: [ + diveComputerServiceProvider.overrideWithValue(service), + discoveryNotifierProvider.overrideWith( + (ref) => _SeededDiscoveryNotifier(service: service, seed: _seed), + ), + diveComputerRepositoryProvider.overrideWithValue(deps.computerRepo), + deviceDescriptorsProvider.overrideWith((ref) async => []), + firstSyncCutoffDefaultProvider.overrideWith((ref) async => null), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: DcAdapterDownloadStep( + adapter: adapter, + knownComputer: computer, + ), + ), + ), + ); + } +} + +/// The step settles through several async gates (scan resolution, descriptor +/// lookup, the download widget's post-frame auto-start); a handful of plain +/// pumps covers them. pumpAndSettle cannot be used because the download +/// widget shows an indeterminate progress indicator. +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 4; i++) { + await tester.pump(); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // Issue #1232: tapping a saved Petrel 3 and downloading failed with + // connect_failed on Android and Windows, while the scan-and-download flow + // for the very same computer worked. The saved-computer path connected to + // the stored address without a preceding scan; it now re-acquires the + // device by scanning for that address first. + group('DcAdapterDownloadStep saved-computer re-acquisition', () { + testWidgets( + 'scans for the saved address and downloads from the advertised device', + (tester) async { + final h = _Harness(); + await tester.pumpWidget(h.build(_savedComputer())); + await tester.pump(); + + expect(h.hostApi.calls, ['startDiscovery']); + expect(find.text('Searching for My Petrel...'), findsOneWidget); + expect(find.byType(DownloadStepWidget), findsNothing); + + h.service.onDeviceDiscovered(_advert('11:22:33:44:55:66')); + h.service.onDeviceDiscovered(_advert(_savedAddress)); + await _settle(tester); + + expect(h.hostApi.calls, [ + 'startDiscovery', + 'stopDiscovery', + 'startDownload', + ]); + final sent = h.hostApi.downloads.single; + expect(sent.address, _savedAddress); + // The freshly advertised device carries the descriptor the driver + // needs, which the synthesized fallback could only guess at. + expect(sent.vendor, 'Shearwater'); + expect(sent.product, 'Petrel 3'); + expect(sent.model, 10); + }, + ); + + testWidgets( + 'falls back to the stored address when the scan does not see the ' + 'device', + (tester) async { + final h = _Harness(); + await tester.pumpWidget(h.build(_savedComputer())); + await tester.pump(); + expect(h.hostApi.calls, ['startDiscovery']); + + await tester.pump( + DcAdapterDownloadStep.knownDeviceScanTimeout + + const Duration(seconds: 1), + ); + await _settle(tester); + + expect(h.hostApi.calls, [ + 'startDiscovery', + 'stopDiscovery', + 'startDownload', + ]); + expect(h.hostApi.downloads.single.address, _savedAddress); + }, + ); + + testWidgets('does not scan when discovery already holds the device for the ' + 'saved address', (tester) async { + final h = _Harness( + seed: DiscoveryState(selectedDevice: _discovered(_savedAddress)), + ); + await tester.pumpWidget(h.build(_savedComputer())); + await _settle(tester); + + expect(h.hostApi.calls, ['startDownload']); + expect(h.hostApi.downloads.single.address, _savedAddress); + }); + + testWidgets( + 'ignores a previously selected device with a different address', + (tester) async { + final h = _Harness( + seed: DiscoveryState( + selectedDevice: _discovered('11:22:33:44:55:66'), + ), + ); + await tester.pumpWidget(h.build(_savedComputer())); + await tester.pump(); + + expect(h.hostApi.calls, ['startDiscovery']); + expect(find.byType(DownloadStepWidget), findsNothing); + + h.service.onDeviceDiscovered(_advert(_savedAddress)); + await _settle(tester); + + expect(h.hostApi.downloads.single.address, _savedAddress); + }, + ); + + testWidgets('does not scan for a USB computer', (tester) async { + final h = _Harness(); + await tester.pumpWidget( + h.build( + _savedComputer( + connectionType: 'usb', + bluetoothAddress: '/dev/ttyUSB0', + ), + ), + ); + await _settle(tester); + + expect(h.hostApi.calls, ['startDownload']); + expect(h.hostApi.downloads.single.address, '/dev/ttyUSB0'); + }); + }); +} From d441d77d08955d21827ba1e80e1358fc3d581efc Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:14:38 -0400 Subject: [PATCH 027/122] test(3d): pin that the sheet applies each system inset exactly once Review asked whether the SafeArea in the builder double-applies insets alongside useSafeArea. It does not, and removing it would be a regression: useSafeArea inserts SafeArea(bottom: false), so it covers top, left and right and deliberately lets the sheet run to the bottom edge of the screen. The inner SafeArea supplies the bottom inset that the outer one skips. Nothing is applied twice because a SafeArea strips the padding it consumes out of the MediaQuery, so the inner one reads zero for the horizontal edges. The new test states all of that as geometry: with 44/30/30/34 insets on an 400x800 screen the sheet spans the horizontal safe area once and reaches the bottom edge, while its body stops 34px short. Dropping the inner SafeArea moves that body edge from 766 to 800, putting the last control under the home indicator, and the test fails. --- .../widgets/terrain_appearance_sheet.dart | 6 ++++ .../terrain_appearance_sheet_test.dart | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart b/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart index 572ee8da3b..8a564cb898 100644 --- a/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart +++ b/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart @@ -29,6 +29,12 @@ void showTerrainAppearanceSheet(BuildContext context) { // The custom-level rows open a keypad. Without a viewInsets pad the sheet // keeps its full height, so the lower rows and the add button sit behind // the keyboard with no scroll extent left to bring them up (issue #1094). + // Not redundant with useSafeArea: that inserts SafeArea(bottom: false), + // so the sheet deliberately runs to the bottom edge of the screen. This + // one supplies the bottom inset the outer one skips, keeping the last + // control clear of the home indicator. Nothing is applied twice -- a + // SafeArea strips the padding it consumes out of the MediaQuery, so the + // horizontal insets are already zero by the time this one reads them. builder: (sheetContext) => SafeArea( child: Padding( key: const ValueKey('terrainAppearanceSheetInsets'), diff --git a/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart b/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart index 7d10ee42ec..ae799f3774 100644 --- a/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart +++ b/test/features/dive_3d/presentation/widgets/terrain_appearance_sheet_test.dart @@ -81,6 +81,7 @@ void main() { Future pumpSheetRoute( WidgetTester tester, { AppSettings initial = const AppSettings(), + EdgeInsets systemPadding = EdgeInsets.zero, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -98,6 +99,12 @@ void main() { locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + // MaterialApp.builder wraps the Navigator, so padding stated here is + // what the modal route's own SafeArea sees. + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: systemPadding), + child: child!, + ), home: Scaffold( body: Builder( builder: (context) => TextButton( @@ -156,6 +163,34 @@ void main() { expect(tester.getRect(closeButton), before); }); + // `useSafeArea: true` inserts `SafeArea(bottom: false)`, so it covers top, + // left and right and deliberately lets the sheet run to the bottom edge -- + // the SDK doc says so outright. The SafeArea in the builder is what applies + // the bottom inset, and it is not a double application: SafeArea strips the + // padding it consumes out of the MediaQuery, so each inset lands once. + testWidgets('every system inset is applied exactly once', (tester) async { + const insets = EdgeInsets.only(top: 44, left: 30, right: 30, bottom: 34); + await tester.binding.setSurfaceSize(const Size(400, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await pumpSheetRoute(tester, systemPadding: insets); + + final screen = tester.getRect(find.byType(MaterialApp)); + final sheet = tester.getRect(find.byType(BottomSheet)); + final body = tester.getRect( + find.byKey(const ValueKey('terrainAppearanceSheetInsets')), + ); + + // Outer SafeArea: horizontal insets once, and no bottom inset at all. + expect(sheet.left, insets.left); + expect(sheet.right, screen.right - insets.right); + expect(sheet.bottom, screen.bottom); + // Inner SafeArea: the bottom inset the outer one skipped, and nothing + // horizontal on top of what the outer one already applied. + expect(body.bottom, screen.bottom - insets.bottom); + expect(body.left, sheet.left); + expect(body.right, sheet.right); + }); + testWidgets('banded switch writes through to settings', (tester) async { final container = await pumpSheet(tester); expect( From 3eb22b579cf96323e402c4a78538ceb06982ff29 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:27:12 -0400 Subject: [PATCH 028/122] feat(logs): name the build in every debug log export A pasted debug log carried no app version, platform or OS version, so triaging issue #1246 meant inferring the reporter's build from a numeric score inside a BLE selection line and from the absence of a log statement newer builds emit. Attaching the environment makes that a read. New LogEnvironment captures app version plus build, platform, OS version, locale and build mode. copyFilteredLogs, shareLogFile and saveLogFile now prepend a header block, generated at export time rather than read back from the file: the file's head is exactly what log rotation discards and what the viewer's category and severity filters exclude, and #1246 arrived as a filtered copy. capture() never throws and always completes. The version lookup is bounded by a 2 second timeout because a channel that never answers is as damaging as one that throws, and that is not hypothetical: PackageInfo.fromPlatform never completes under testWidgets, and a headless isolate has no plugin registrant. A packageInfoLoader seam makes both failure modes testable. main.dart writes a single-line "Session start" marker when debug logging is already on, so a log file spanning several app versions attributes each run to its build. It stays single-line so LogEntry.tryParse accepts it; readEntries silently drops anything that does not parse. DebugModeNotifier.enable deliberately does not emit one, with a comment saying why: a background lookup plus a file write outliving a settings toggle is a timer and a real-IO future escaping the widget test that flipped it, and the export header already covers that flow. Share and save concatenate bytes rather than a decoded string, so one malformed UTF-8 sequence cannot cost the user the whole export. formatAppVersion and formatVersionWithBuild avoid a fourth copy of the version-plus-build idiom. --- lib/core/services/log_environment.dart | 150 +++++++++++++++ lib/core/utils/app_version.dart | 20 ++ .../providers/debug_log_providers.dart | 79 ++++++-- .../providers/debug_mode_provider.dart | 7 + lib/main.dart | 6 + test/core/services/log_environment_test.dart | 172 ++++++++++++++++++ test/core/utils/app_version_test.dart | 26 +++ .../pages/debug_log_viewer_page_test.dart | 14 ++ .../providers/debug_log_providers_test.dart | 74 +++++++- 9 files changed, 525 insertions(+), 23 deletions(-) create mode 100644 lib/core/services/log_environment.dart create mode 100644 lib/core/utils/app_version.dart create mode 100644 test/core/services/log_environment_test.dart create mode 100644 test/core/utils/app_version_test.dart diff --git a/lib/core/services/log_environment.dart b/lib/core/services/log_environment.dart new file mode 100644 index 0000000000..396a9a5128 --- /dev/null +++ b/lib/core/services/log_environment.dart @@ -0,0 +1,150 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' + show kDebugMode, kProfileMode, visibleForTesting; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:submersion/core/models/log_entry.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/utils/app_version.dart'; + +/// Value shown wherever a field could not be determined. +const _unknown = 'unknown'; + +/// A snapshot of the build and device that produced a debug log. +/// +/// Bug reports arrive as pasted log excerpts, and every triage starts with the +/// same question: which build wrote these lines? Issue #1246 is the worked +/// example: an OSTC Sport download failure that had already been fixed +/// months earlier, but the report carried no version, so establishing that the +/// reporter was simply on an older build meant inferring it from a numeric +/// score in a BLE selection log line and from the *absence* of a log statement +/// that newer builds emit. Attaching this to the logs makes that a read +/// instead of an inference. +class LogEnvironment { + /// Four-segment app version, e.g. `1.7.6.123`. + final String appVersion; + + /// Operating system identifier, e.g. `ios`, `android`, `macos`. + final String platform; + + /// Full OS version string, e.g. `Version 26.6 (Build 23G93)`. + final String osVersion; + + /// Host locale, e.g. `de_DE.UTF-8`. + final String locale; + + /// Flutter build mode: `release`, `profile` or `debug`. + final String buildMode; + + /// When this snapshot was taken. + /// + /// A field rather than a `DateTime.now()` inside [toExportHeader] so the + /// header is a pure function of the value: rendering it twice must produce + /// the same string. Capture happens at export time, so this is also the + /// moment the export was produced. + final DateTime capturedAt; + + const LogEnvironment({ + required this.appVersion, + required this.platform, + required this.osVersion, + required this.locale, + required this.buildMode, + required this.capturedAt, + }); + + /// How long [capture] waits for the app version before giving up. + /// + /// A platform channel that never answers is just as damaging as one that + /// throws: the caller is a user pressing Copy or Share, and an unbounded + /// wait would hang the export outright rather than degrade it. That is not + /// hypothetical, because `PackageInfo.fromPlatform` never completes under + /// `testWidgets`, and a headless isolate has no plugin registrant either. + static const versionLookupTimeout = Duration(seconds: 2); + + /// Seam for the version lookup, so tests can drive its failure modes. + @visibleForTesting + static Future Function() packageInfoLoader = + PackageInfo.fromPlatform; + + /// Read the environment from the platform. + /// + /// Never throws and always completes. Anything that cannot be determined + /// degrades to [_unknown] rather than failing the caller: this only ever + /// decorates a log export or a log line, so losing the app version must not + /// cost the user the logs themselves. + static Future capture({Duration? versionTimeout}) async { + var appVersion = _unknown; + try { + final info = await packageInfoLoader().timeout( + versionTimeout ?? versionLookupTimeout, + ); + appVersion = formatAppVersion(info); + } on Object { + // A throw or a timeout both land here; see the doc comment. + } + + var platform = _unknown; + var osVersion = _unknown; + var locale = _unknown; + try { + platform = Platform.operatingSystem; + osVersion = Platform.operatingSystemVersion; + locale = Platform.localeName; + } on Object { + // Platform getters throw on unsupported hosts (e.g. web). + } + + return LogEnvironment( + appVersion: appVersion, + platform: platform, + osVersion: osVersion, + locale: locale, + buildMode: kDebugMode + ? 'debug' + : kProfileMode + ? 'profile' + : 'release', + capturedAt: DateTime.now(), + ); + } + + /// One-line form, used for the session marker written into the log file. + /// + /// Deliberately a single line so it survives [LogEntry.tryParse] and shows + /// up in the log viewer like any other entry. + String toSummaryLine() => + 'Session start: Submersion $appVersion' + ' | $platform $osVersion' + ' | locale $locale' + ' | $buildMode build'; + + /// Multi-line header prepended to an exported or copied log. + /// + /// Regenerated at export time rather than read back from the file, because + /// the file's head is what log rotation discards and what the viewer's + /// category/severity filters exclude. + String toExportHeader() { + final exportedAt = capturedAt.toIso8601String(); + return ''' +=== Submersion debug log === +app: Submersion $appVersion +platform: $platform $osVersion +locale: $locale +build: $buildMode +exported: $exportedAt +============================ +'''; + } +} + +/// Record the current build and device at the top of a logging session. +/// +/// Called wherever file logging is switched on, so a log file that spans +/// several app versions attributes each run to the build that wrote it. +Future logSessionEnvironment() async { + final environment = await LogEnvironment.capture(); + const LoggerService( + 'Submersion', + ).info(environment.toSummaryLine(), category: LogCategory.app); +} diff --git a/lib/core/utils/app_version.dart b/lib/core/utils/app_version.dart new file mode 100644 index 0000000000..dc1bdd5928 --- /dev/null +++ b/lib/core/utils/app_version.dart @@ -0,0 +1,20 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +/// Format the app version the way the project displays it everywhere: the +/// three-segment marketing version with the build number appended as a fourth +/// segment (`1.7.6.123`). +/// +/// Release tags are four-segment (`vX.Y.Z.N`) while `PackageInfo.version` is +/// the three-segment marketing version, so the build number has to be appended +/// for a version string to be comparable with a tag. Some platforms already +/// report a four-segment version, hence the guard against doubling it. +String formatAppVersion(PackageInfo info) => + formatVersionWithBuild(info.version, info.buildNumber); + +/// [formatAppVersion] without the PackageInfo dependency, for callers that +/// already hold the two parts separately. +String formatVersionWithBuild(String version, String buildNumber) { + if (buildNumber.isEmpty) return version; + if (version.endsWith('.$buildNumber')) return version; + return '$version.$buildNumber'; +} diff --git a/lib/features/settings/presentation/providers/debug_log_providers.dart b/lib/features/settings/presentation/providers/debug_log_providers.dart index 709d25aca5..7ae7d595f0 100644 --- a/lib/features/settings/presentation/providers/debug_log_providers.dart +++ b/lib/features/settings/presentation/providers/debug_log_providers.dart @@ -1,10 +1,13 @@ +import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:submersion/core/models/log_entry.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/log_environment.dart'; import 'package:submersion/core/services/log_file_service.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/export/shared/file_export_utils.dart'; @@ -122,41 +125,91 @@ final filteredLogEntriesProvider = Provider>>((ref) { }); }); +/// Name the exported copy carries in the share sheet and the save dialog. +const _exportFileName = 'submersion-debug-logs.txt'; + +/// Resolve the header prepended to every export. +/// +/// Callers may pass a captured [LogEnvironment]; otherwise it is read here. +/// Attaching the build and device to the logs is what turns a pasted excerpt +/// into something triageable (issue #1246). +Future _exportHeader(LogEnvironment? environment) async { + final resolved = environment ?? await LogEnvironment.capture(); + return resolved.toExportHeader(); +} + +/// The log file's bytes behind the export header. +/// +/// Concatenated as BYTES rather than decoded to a string first: the log +/// carries whatever a device name or a native log message put in it, and +/// `readAsString` throws on a malformed UTF-8 sequence. Losing the whole +/// export to one bad byte is a worse outcome than passing it through. +Future _exportBytes(File file, String header) async { + return Uint8List.fromList([ + ...utf8.encode(header), + ...await file.readAsBytes(), + ]); +} + /// Share the full log file via system share sheet. -Future shareLogFile(LogFileService service, AppLocalizations l10n) async { - final path = service.logFilePath; - final file = File(path); +/// +/// Shares a header-prefixed copy rather than the log file itself, so the +/// recipient sees which build and device produced the lines. +Future shareLogFile( + LogFileService service, + AppLocalizations l10n, { + LogEnvironment? environment, +}) async { + final file = File(service.logFilePath); if (!file.existsSync()) return; + final header = await _exportHeader(environment); + + // Written to the temp directory rather than shared in place: the log file + // has to stay untouched (it is still being appended to), and the copy is + // disposable once the share sheet has read it. Sharing a *copy* is also what + // lets the export carry the header at all. Reusing one name bounds the temp + // directory to a single file across repeated shares. + final tempDir = await getTemporaryDirectory(); + final export = File('${tempDir.path}/$_exportFileName'); + await export.writeAsBytes(await _exportBytes(file, header)); + await SharePlus.instance.share( ShareParams( - files: [XFile(path, mimeType: 'text/plain')], + files: [XFile(export.path, mimeType: 'text/plain')], subject: l10n.settings_debugLog_shareSubject, ), ); } /// Copy the filtered log entries to clipboard. -Future copyFilteredLogs(List entries) async { +/// +/// The header is prepended even when the filters exclude everything: an empty +/// excerpt that still names the build is more useful than a bare empty string. +Future copyFilteredLogs( + List entries, { + LogEnvironment? environment, +}) async { + final header = await _exportHeader(environment); final text = entries.map((e) => e.toLogLine()).join('\n'); - await Clipboard.setData(ClipboardData(text: text)); + await Clipboard.setData(ClipboardData(text: '$header$text')); } /// Save the full log file to a user-chosen location. Future saveLogFile( LogFileService service, - AppLocalizations l10n, -) async { - final path = service.logFilePath; - final file = File(path); + AppLocalizations l10n, { + LogEnvironment? environment, +}) async { + final file = File(service.logFilePath); if (!file.existsSync()) return null; - final bytes = await file.readAsBytes(); + final header = await _exportHeader(environment); final result = await FilePicker.saveFile( dialogTitle: l10n.settings_debugLog_saveDialogTitle, - fileName: 'submersion-debug-logs.txt', + fileName: _exportFileName, type: FileType.custom, - bytes: bytes, + bytes: await _exportBytes(file, header), mimeType: 'text/plain', ); diff --git a/lib/features/settings/presentation/providers/debug_mode_provider.dart b/lib/features/settings/presentation/providers/debug_mode_provider.dart index ac80af2eb0..d40c43d615 100644 --- a/lib/features/settings/presentation/providers/debug_mode_provider.dart +++ b/lib/features/settings/presentation/providers/debug_mode_provider.dart @@ -21,6 +21,13 @@ class DebugModeNotifier extends StateNotifier { state = true; await _prefs.setBool(_kDebugModeKey, true); LoggerService.setFileService(_logFileService); + // Deliberately no session-environment line here, unlike main.dart. It + // would have to be fire-and-forget (a settings toggle must not wait on a + // platform channel), and a background version lookup plus a file write + // outliving the toggle is a timer and a real-IO future escaping whatever + // widget test flipped it. The export header in debug_log_providers.dart + // already names the build on every copy, share and save, which is the + // path a bug report actually travels (issue #1246). } Future disable() async { diff --git a/lib/main.dart b/lib/main.dart index d2aabb42ad..6f6be20370 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/providers/root_overrides.dart'; import 'package:submersion/core/services/global_error_handler.dart'; import 'package:submersion/core/services/log_file_service.dart'; +import 'package:submersion/core/services/log_environment.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/app.dart'; @@ -73,6 +74,11 @@ Future _bootstrap() async { final debugEnabled = prefs.getBool('debug_mode_enabled') ?? false; if (debugEnabled) { LoggerService.setFileService(logFileService); + // Stamp the build and device at the top of the session so a log file that + // spans several app versions attributes each run to the build that wrote + // it (issue #1246). Not awaited: startup must not block on a platform + // channel, and the write is serialized behind LoggerService's queue. + unawaited(logSessionEnvironment()); } // Create location service and get storage config diff --git a/test/core/services/log_environment_test.dart b/test/core/services/log_environment_test.dart new file mode 100644 index 0000000000..cd95bf0f4c --- /dev/null +++ b/test/core/services/log_environment_test.dart @@ -0,0 +1,172 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:submersion/core/models/log_entry.dart'; +import 'package:submersion/core/services/log_environment.dart'; +import 'package:submersion/core/services/log_file_service.dart'; +import 'package:submersion/core/services/logger_service.dart'; + +void main() { + final environment = LogEnvironment( + appVersion: '1.7.6.123', + platform: 'ios', + osVersion: 'Version 26.6 (Build 23G93)', + locale: 'de_DE.UTF-8', + buildMode: 'release', + capturedAt: DateTime(2026, 8, 25, 20, 25, 19), + ); + + group('toSummaryLine', () { + test('names the build, platform, locale and build mode', () { + final line = environment.toSummaryLine(); + + expect(line, contains('1.7.6.123')); + expect(line, contains('ios')); + expect(line, contains('Version 26.6 (Build 23G93)')); + expect(line, contains('de_DE.UTF-8')); + expect(line, contains('release')); + }); + + test('is a single line so it survives the log-line parser', () { + // LogFileService.readEntries drops anything LogEntry.tryParse rejects, + // and the parser is line-oriented: a multi-line summary would be + // silently discarded from the log viewer. + expect(environment.toSummaryLine(), isNot(contains('\n'))); + }); + }); + + group('toExportHeader', () { + test('carries every field an incoming bug report needs', () { + final header = environment.toExportHeader(); + + expect(header, contains('1.7.6.123')); + expect(header, contains('ios')); + expect(header, contains('Version 26.6 (Build 23G93)')); + expect(header, contains('de_DE.UTF-8')); + expect(header, contains('release')); + expect(header, contains('exported:')); + }); + + test('ends with a newline so log lines start on their own line', () { + expect(environment.toExportHeader(), endsWith('\n')); + }); + + test('renders identically twice', () { + // The exported timestamp is a captured field, not a DateTime.now() + // inside the getter, so callers can compare two renderings. + expect(environment.toExportHeader(), environment.toExportHeader()); + expect(environment.toExportHeader(), contains('2026-08-25T20:25:19')); + }); + }); + + group('logSessionEnvironment', () { + late Directory tempDir; + late LogFileService service; + + setUp(() async { + tempDir = Directory.systemTemp.createTempSync('log_environment_test_'); + service = LogFileService(logDirectory: tempDir.path); + await service.initialize(); + LoggerService.setFileService(service); + }); + + tearDown(() { + LoggerService.setFileService(null); + tempDir.deleteSync(recursive: true); + }); + + test('writes an entry the log file parser accepts', () async { + // LogFileService.readEntries silently drops anything tryParse rejects, + // so a session marker that does not round-trip would never reach the + // log viewer at all. + await logSessionEnvironment(); + await LoggerService.flushPendingWrites(); + + final entries = await service.readEntries(); + + expect(entries, hasLength(1)); + expect(entries.single.message, startsWith('Session start: Submersion')); + expect(entries.single.category, LogCategory.app); + expect(entries.single.level, LogLevel.info); + }); + + test('records the platform the run happened on', () async { + await logSessionEnvironment(); + await LoggerService.flushPendingWrites(); + + final entries = await service.readEntries(); + + expect(entries.single.message, contains(Platform.operatingSystem)); + }); + }); + + group('capture', () { + test('never throws when the platform channel is unavailable', () async { + // PackageInfo.fromPlatform needs a platform channel that does not exist + // under flutter test. Losing the version must not cost the caller the + // logs, so capture degrades instead of failing. + final captured = await LogEnvironment.capture(); + + expect(captured.appVersion, 'unknown'); + }); + + test('reads the real platform identity', () async { + final captured = await LogEnvironment.capture(); + + expect(captured.platform, Platform.operatingSystem); + expect(captured.osVersion, Platform.operatingSystemVersion); + expect(captured.locale, Platform.localeName); + }); + + test('degrades when the version lookup never answers', () async { + // An unbounded wait would hang a Copy or Share outright. Proven + // reachable: PackageInfo.fromPlatform never completes under testWidgets. + LogEnvironment.packageInfoLoader = () => Completer().future; + addTearDown(() { + LogEnvironment.packageInfoLoader = PackageInfo.fromPlatform; + }); + + final captured = await LogEnvironment.capture( + versionTimeout: const Duration(milliseconds: 20), + ); + + expect(captured.appVersion, 'unknown'); + expect(captured.platform, Platform.operatingSystem); + }); + + test('uses the version when the lookup answers', () async { + LogEnvironment.packageInfoLoader = () async => PackageInfo( + appName: 'Submersion', + packageName: 'app.submersion', + version: '1.7.6', + buildNumber: '123', + buildSignature: '', + ); + addTearDown(() { + LogEnvironment.packageInfoLoader = PackageInfo.fromPlatform; + }); + + final captured = await LogEnvironment.capture(); + + expect(captured.appVersion, '1.7.6.123'); + }); + + test('reports the build mode flutter test runs in', () async { + final captured = await LogEnvironment.capture(); + + expect(captured.buildMode, anyOf('debug', 'profile', 'release')); + }); + + test( + 'still produces a usable header when the version is unknown', + () async { + final captured = await LogEnvironment.capture(); + + expect(captured.toExportHeader(), contains('Submersion debug log')); + expect(captured.toExportHeader(), contains(Platform.operatingSystem)); + }, + ); + }); +} diff --git a/test/core/utils/app_version_test.dart b/test/core/utils/app_version_test.dart new file mode 100644 index 0000000000..e0f4143e42 --- /dev/null +++ b/test/core/utils/app_version_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/utils/app_version.dart'; + +void main() { + group('formatVersionWithBuild', () { + test('appends the build number as a fourth segment', () { + expect(formatVersionWithBuild('1.7.6', '123'), '1.7.6.123'); + }); + + test('does not double a build number the version already carries', () { + // Some platforms report a four-segment version. Without this guard a + // release comparison would see "1.7.6.123.123" and never match its tag. + expect(formatVersionWithBuild('1.7.6.123', '123'), '1.7.6.123'); + }); + + test('leaves the version alone when there is no build number', () { + expect(formatVersionWithBuild('1.7.6', ''), '1.7.6'); + }); + + test('appends a build number that merely repeats a digit run', () { + // "1.7.23" does not end with ".3" as a segment even though it ends with + // the characters; endsWith('.3') is false here, so nothing is swallowed. + expect(formatVersionWithBuild('1.7.23', '3'), '1.7.23.3'); + }); + }); +} diff --git a/test/features/settings/presentation/pages/debug_log_viewer_page_test.dart b/test/features/settings/presentation/pages/debug_log_viewer_page_test.dart index 01fad3a02a..47edc061b1 100644 --- a/test/features/settings/presentation/pages/debug_log_viewer_page_test.dart +++ b/test/features/settings/presentation/pages/debug_log_viewer_page_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/models/log_entry.dart'; import 'package:submersion/core/providers/provider.dart'; @@ -370,6 +371,19 @@ void main() { ); await tester.pumpAndSettle(); + // copyFilteredLogs prefixes the export header, which reads the app + // version (issue #1246). PackageInfo.fromPlatform NEVER completes under + // testWidgets, so without this mock the handler stays suspended and the + // snackbar is never scheduled. Production is covered by the timeout in + // LogEnvironment.capture; here the normal path is what we want to test. + PackageInfo.setMockInitialValues( + appName: 'Submersion', + packageName: 'app.submersion', + version: '1.7.6', + buildNumber: '123', + buildSignature: '', + ); + // Tap the Copy button await tester.tap(find.text('Copy')); await tester.pumpAndSettle(); diff --git a/test/features/settings/presentation/providers/debug_log_providers_test.dart b/test/features/settings/presentation/providers/debug_log_providers_test.dart index b0089aeb3d..43d3be848f 100644 --- a/test/features/settings/presentation/providers/debug_log_providers_test.dart +++ b/test/features/settings/presentation/providers/debug_log_providers_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:submersion/core/models/log_entry.dart'; +import 'package:submersion/core/services/log_environment.dart'; import 'package:submersion/core/services/log_file_service.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/settings/presentation/providers/debug_log_providers.dart'; @@ -17,6 +18,16 @@ import 'package:submersion/l10n/l10n_extension.dart'; /// subject and dialog title from the app's translations. final _l10n = l10nForLocaleTag('en'); +/// Fixed environment so the export header is byte-comparable across renders. +final _environment = LogEnvironment( + appVersion: '1.7.6.123', + platform: 'ios', + osVersion: 'Version 26.6 (Build 23G93)', + locale: 'de_DE.UTF-8', + buildMode: 'release', + capturedAt: DateTime(2026, 8, 25, 20, 25, 19), +); + LogEntry _entry({ required String message, LogCategory category = LogCategory.app, @@ -702,7 +713,7 @@ void main() { ), ]; - await copyFilteredLogs(entries); + await copyFilteredLogs(entries, environment: _environment); final setDataCall = clipboardCalls.firstWhere( (c) => c.method == 'Clipboard.setData', @@ -712,22 +723,57 @@ void main() { expect(text, contains(entries[0].toLogLine())); expect(text, contains(entries[1].toLogLine())); - // Lines joined by newline. + // Header, then the lines joined by newline. expect( text, - equals('${entries[0].toLogLine()}\n${entries[1].toLogLine()}'), + equals( + '${_environment.toExportHeader()}' + '${entries[0].toLogLine()}\n${entries[1].toLogLine()}', + ), + ); + }); + + test('prefixes the header naming the build that wrote the logs', () async { + // Issue #1246: a pasted log excerpt with no version behind it cost a + // full investigation to attribute to a build. + await copyFilteredLogs([ + _entry(message: 'alpha'), + ], environment: _environment); + + final setDataCall = clipboardCalls.firstWhere( + (c) => c.method == 'Clipboard.setData', + ); + final text = + (setDataCall.arguments as Map)['text'] as String; + + expect(text, startsWith('=== Submersion debug log ===')); + expect(text, contains('1.7.6.123')); + expect(text, contains('ios')); + }); + + test('copies the header alone when entries list is empty', () async { + // An excerpt filtered down to nothing still names the build, which is + // more useful than the bare empty string this used to produce. + await copyFilteredLogs([], environment: _environment); + + final setDataCall = clipboardCalls.firstWhere( + (c) => c.method == 'Clipboard.setData', ); + final text = + (setDataCall.arguments as Map)['text'] as String; + expect(text, equals(_environment.toExportHeader())); }); - test('copies empty string when entries list is empty', () async { - await copyFilteredLogs([]); + test('captures the environment itself when none is supplied', () async { + await copyFilteredLogs([_entry(message: 'alpha')]); final setDataCall = clipboardCalls.firstWhere( (c) => c.method == 'Clipboard.setData', ); final text = (setDataCall.arguments as Map)['text'] as String; - expect(text, isEmpty); + + expect(text, startsWith('=== Submersion debug log ===')); }); }); @@ -740,7 +786,7 @@ void main() { await service.initialize(); // No entries written, so log file doesn't exist - await shareLogFile(service, _l10n); + await shareLogFile(service, _l10n, environment: _environment); // Should complete without error }); @@ -756,7 +802,7 @@ void main() { // SharePlus may throw MissingPluginException in test env. // The key is that we reach the share call (covering those lines). try { - await shareLogFile(service, _l10n); + await shareLogFile(service, _l10n, environment: _environment); } catch (_) { // Expected in test environment } @@ -771,7 +817,11 @@ void main() { final service = LogFileService(logDirectory: tempDir.path); await service.initialize(); - final result = await saveLogFile(service, _l10n); + final result = await saveLogFile( + service, + _l10n, + environment: _environment, + ); expect(result, isNull); }); @@ -787,7 +837,11 @@ void main() { // FilePicker may throw MissingPluginException in test env. // The key is that we reach the FilePicker call (covering those lines). try { - final result = await saveLogFile(service, _l10n); + final result = await saveLogFile( + service, + _l10n, + environment: _environment, + ); // If it somehow succeeds (returns null from picker), that's fine expect(result, anything); } catch (_) { From 1fd5b74df17d42d84850ded9705b14a5320273e6 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:27:22 -0400 Subject: [PATCH 029/122] fix(libdc): resolve an OSTC Sport to its own descriptor, not OSTC 2 An OSTC Sport advertises "OSTCs" plus its serial ("OSTCs 21211", issue #1246) and the app labelled it "Heinrichs Weikamp OSTC 2". dc_filter_hw accepts any "OSTC*" name for every hw_ostc3 descriptor, and "OSTCs" is an abbreviation rather than a prefix of "OSTC Sport", so both tiebreakers in libdc_descriptor_match scored zero and the first family row won. The existing uwatec_ble_alias_model table cannot solve this: it disambiguates by model code, and descriptor.c gives OSTC 2, 2 TR, 3, cR, Plus, Sport and Nano all model 0. Only OSTC 4 and OSTC 5 differ, which is why those two already resolved correctly. The new ble_alias_product table therefore maps an advertised name to a product string. Aliases are compared with the existing product_prefix_len, so one entry covers "OSTCs", "OSTCs 21211" and "ostcs-21211" while still refusing "OSTCsomething": that helper already rejects a match ending mid-word. Every affected descriptor carries the same model code, so downloads are unchanged and this is purely the displayed name. libdc_wrapper.c is one shared source across darwin, Windows, Linux and Android, so the fix lands on every platform at once. --- .../macos/Classes/libdc_wrapper.c | 59 +++++++++++++++++++ .../test_descriptor_match_integration.c | 28 +++++++++ 2 files changed, 87 insertions(+) diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.c b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.c index 3e4f0fcb82..988492bea6 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.c +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.c @@ -175,6 +175,43 @@ static unsigned int uwatec_ble_alias_model(const char *name) { return 0; } +// Some dive computers advertise an ABBREVIATED BLE name that is neither the +// libdivecomputer product string nor a prefix of it, so neither the exact-name +// nor the longest-prefix tiebreaker below can reach the right descriptor. The +// Uwatec aliases above solve the same problem by model code, but that only +// works where the models differ: every hw_ostc3 descriptor except OSTC 4 and +// OSTC 5 carries model 0, so a Heinrichs Weikamp alias has to name its PRODUCT +// instead. +// +// Issue #1246: an OSTC Sport advertises "OSTCs" followed by its serial +// ("OSTCs 21211"). dc_filter_hw accepts any "OSTC*" name for every hw_ostc3 +// row, and "OSTCs" is not a prefix of "OSTC Sport", so the matcher fell back +// to the first family row and reported the device as an "OSTC 2". The model is +// the same either way, so this is a labelling fix, not a download fix. +// +// Aliases are compared with product_prefix_len, so an entry also covers the +// serial-suffixed form and will not match a longer word ("OSTCsomething"). +// Only add an alias with a real advertised name behind it: a wrong guess here +// silently mislabels hardware. Returns NULL when the name is not a known +// alias. +static const char *ble_alias_product(const char *name) { + static const struct { + const char *alias; + const char *product; + } aliases[] = { + {"OSTCs", "OSTC Sport"}, // issue #1246 + }; + if (name == NULL) { + return NULL; + } + for (size_t i = 0; i < sizeof(aliases) / sizeof(aliases[0]); i++) { + if (product_prefix_len(name, aliases[i].alias) > 0) { + return aliases[i].product; + } + } + return NULL; +} + int libdc_descriptor_match(const char *name, unsigned int transport, libdc_descriptor_info_t *info) { if (name == NULL || info == NULL) { @@ -229,6 +266,15 @@ int libdc_descriptor_match(const char *name, unsigned int transport, } } + // Advertised names that abbreviate their product rather than prefixing it + // (see ble_alias_product). Resolved to a product string because the + // hw_ostc3 family shares one model code, which the exact-model preference + // above cannot tell apart. Issue #1246. + const char *alias_product = NULL; + if (!has_name_model && (transport & LIBDC_TRANSPORT_BLE)) { + alias_product = ble_alias_product(name); + } + dc_descriptor_t *desc = NULL; int found = 0; size_t best_prefix_len = 0; @@ -267,6 +313,19 @@ int libdc_descriptor_match(const char *name, unsigned int transport, break; } + // An abbreviated advertised name reaches its product through + // the alias table; that beats the prefix tiebreaker below, + // which by definition cannot match an abbreviation. + if (alias_product && product && + strcasecmp_nospace(alias_product, product) == 0) { + info->vendor = dc_descriptor_get_vendor(desc); + info->product = product; + info->model = dc_descriptor_get_model(desc); + info->transports = dc_descriptor_get_transports(desc); + dc_descriptor_free(desc); + break; + } + // No exact match: prefer the descriptor whose product is // the LONGEST prefix of the advertised name, so a // serial-suffixed name resolves to its own model instead diff --git a/packages/libdivecomputer_plugin/test/native/test_descriptor_match_integration.c b/packages/libdivecomputer_plugin/test/native/test_descriptor_match_integration.c index 2f5cd4acc8..ed5402b784 100644 --- a/packages/libdivecomputer_plugin/test/native/test_descriptor_match_integration.c +++ b/packages/libdivecomputer_plugin/test/native/test_descriptor_match_integration.c @@ -114,6 +114,32 @@ static void test_hw_ostc_suffixed_names_resolve(void) { printf("PASS: test_hw_ostc_suffixed_names_resolve\n"); } +// Issue #1246: an OSTC Sport advertises "OSTCs" plus its serial. That is an +// ABBREVIATION, not a prefix of "OSTC Sport", so neither the exact-name nor +// the longest-prefix tiebreaker could reach the right row and the device was +// reported as an "OSTC 2". The alias table resolves it by product name, +// because the whole hw_ostc3 family below OSTC 4 shares model 0 and so cannot +// be told apart by model code. +static void test_hw_ostc_sport_alias_resolves(void) { + expect_ble_match("OSTCs 21211", "OSTC Sport", 0); + expect_ble_match("OSTCs", "OSTC Sport", 0); + expect_ble_match("ostcs 21211", "OSTC Sport", 0); + // The spelled-out product must keep resolving through the ordinary + // exact/prefix path rather than depending on the alias. + expect_ble_match("OSTC Sport", "OSTC Sport", 0); + expect_ble_match("OSTC Sport 4711", "OSTC Sport", 0); + printf("PASS: test_hw_ostc_sport_alias_resolves\n"); +} + +// The alias must not swallow a longer word that merely starts with it: +// product_prefix_len rejects a match that ends mid-word, so a hypothetical +// "OSTCsomething" falls back to the family row instead of claiming to be a +// Sport. +static void test_hw_ostc_alias_does_not_match_longer_word(void) { + expect_ble_match("OSTCsomething", "OSTC 2", 0); + printf("PASS: test_hw_ostc_alias_does_not_match_longer_word\n"); +} + // Issue #483 regression guard: dc_filter_shearwater passes a whitelisted name // for EVERY Shearwater row, so resolution relies on the wrapper preferring the // row whose product exactly equals the BLE name. The new "Perdix 3" row must @@ -149,6 +175,8 @@ int main(void) { test_non_uwatec_device_unaffected(); test_perdix_3_resolves(); test_hw_ostc_suffixed_names_resolve(); + test_hw_ostc_sport_alias_resolves(); + test_hw_ostc_alias_does_not_match_longer_word(); test_other_perdix_models_unchanged(); test_symbios_handset_resolves_to_handset(); test_symbios_hud_resolves_to_hud(); From 6ac18235f4f8954a951687f959c8e304047c54c2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:29:05 -0400 Subject: [PATCH 030/122] fix(media_store): resume a stalled transfer queue instead of freezing it A media transfer queue that stopped for any reason had no way back. Every drain trigger lives downstream of mediaStoreRuntimeProvider already existing in the current process, that provider is lazily built, and nothing at launch or on resume ever read it. The reporter's 196 rows survived every restart untouched (#1270). The display surfaces cannot stand in for a real trigger. MediaItemView only reaches the store for a row that is already backed up (its storeConfirmed gate), so on a device that has never finished an upload no row qualifies: browsing never builds the runtime, nothing drains, and nothing ever becomes storeConfirmed. Mobile hides this because lifecycle churn and constant media viewing build the runtime anyway; a desktop app sits in one process for days. Four changes: - mediaTransferResumeProvider, called from app.dart at launch and on every resume. Two cheap guards run first, because building the runtime opens the keychain and reads the store marker out of the bucket: mediaStoreAttachedProvider is one prefs read, and nextPending is one indexed local read meaning "there is work a drain could take right now". - TransfersPage resolves the runtime in initState. The dashboard's "N uploads pending" chip pushes straight there, and the route is a plain go_router builder nested under media-storage, so MediaStoragePage never runs on the way in. Someone arriving to ask why nothing is uploading was shown the stuck rows and nothing else. - MediaUploadPipeline.process catches on Object rather than on Exception. It marks the row transferring before doing any work and nextPending never selects that state, so an escaping Error left the row invisible to the drainer: unretryable and unclearable from the Transfers UI. StateError from an uninitialized singleton and OutOfMemoryError from staging a large original are both Errors. - A per-entry budget in the drain loop, plus a shorter one on the preflight, which does an untimed marker GET before every entry. The budget stops the drain waiting, not the transfer: Dart cannot cancel a Future, so the work keeps running and still owns its row, which is what makes moving on safe. This fixes the persistence of the bug rather than the original trigger for that first stalled drain, which is not determinable from the report. The macOS Photos path is not at fault: platformGallery rows carry a non-null platformAssetId, the type is in kUploadableSources, and photo_manager's originBytes reaches macOS via originFile/getFullFile. Closes #1270 --- lib/app.dart | 23 ++ .../media_store/data/media_store_worker.dart | 74 +++++- .../data/media_upload_pipeline.dart | 13 +- .../presentation/pages/transfers_page.dart | 22 ++ .../providers/media_store_providers.dart | 45 ++++ .../media_store_worker_budget_test.dart | 231 ++++++++++++++++++ .../media_transfer_resume_provider_test.dart | 109 +++++++++ .../media_upload_pipeline_test.dart | 50 ++++ .../media_store/transfers_page_test.dart | 38 +++ 9 files changed, 600 insertions(+), 5 deletions(-) create mode 100644 test/features/media_store/media_store_worker_budget_test.dart create mode 100644 test/features/media_store/media_transfer_resume_provider_test.dart diff --git a/lib/app.dart b/lib/app.dart index 5e7d1142f6..220a07e7d8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -19,6 +20,7 @@ import 'package:submersion/features/auto_update/presentation/providers/update_me import 'package:submersion/features/backup/presentation/pages/restore_complete_page.dart'; import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; import 'package:submersion/features/backup/presentation/widgets/restore_barrier.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; import 'package:submersion/features/settings/presentation/widgets/adopt_replaced_library_dialog.dart'; @@ -107,6 +109,7 @@ class _SubmersionAppState extends ConsumerState ); WidgetsBinding.instance.addPostFrameCallback((_) { _maybeSyncOnLaunch(); + _resumeMediaTransfers(); _fileShareHandler.initialize(); }); } @@ -150,9 +153,29 @@ class _SubmersionAppState extends ConsumerState if (state == AppLifecycleState.resumed) { ref.read(appLockNotifierProvider.notifier).noteResumed(); _maybeSyncOnResume(); + _resumeMediaTransfers(); } } + /// Restarts an outstanding media transfer queue (issue #1270). + /// + /// On launch and on every resume, because the queue's own triggers all live + /// downstream of a runtime this process may never have built: a desktop app + /// sits in one process for days, and a queue that stopped mid-import stayed + /// stopped through every restart. The provider does the deciding - it + /// short-circuits on an unattached device or an empty queue before anything + /// expensive - and contains its own failures, which is what makes this call + /// safe to leave unawaited. + /// + /// Safe to reach the local cache database from here: StartupWrapper mounts + /// SubmersionRestart (and so this widget) only once `_state` is ready, which + /// it sets after `_initializeServices()` returns - and that awaits + /// `LocalCacheDatabaseService.instance.initialize`. This runs a post-frame + /// callback later still. + void _resumeMediaTransfers() { + unawaited(ref.read(mediaTransferResumeProvider)()); + } + Future _maybeSyncOnLaunch() async { final settings = ref.read(syncBehaviorProvider); if (!settings.autoSyncEnabled || !settings.syncOnLaunch) return; diff --git a/lib/features/media_store/data/media_store_worker.dart b/lib/features/media_store/data/media_store_worker.dart index c7cb668363..2027503962 100644 --- a/lib/features/media_store/data/media_store_worker.dart +++ b/lib/features/media_store/data/media_store_worker.dart @@ -21,11 +21,15 @@ class MediaStoreWorker { MediaDeleteProcessor? deleteProcessor, Future Function()? preflight, Future Function(MediaTransferQueueEntry entry)? gate, + Duration entryBudget = defaultEntryBudget, + Duration preflightBudget = defaultPreflightBudget, }) : _queue = queue, _pipeline = pipeline, _deleteProcessor = deleteProcessor, _preflight = preflight, - _gate = gate; + _gate = gate, + _entryBudget = entryBudget, + _preflightBudget = preflightBudget; final MediaTransferQueueRepository _queue; final MediaUploadPipeline _pipeline; @@ -45,6 +49,26 @@ class MediaStoreWorker { /// Deferral window for policy/connectivity-blocked entries. static const Duration deferWindow = Duration(minutes: 10); + /// How long the drain waits on one entry before moving to the next. + /// + /// Generous on purpose. This is not a policy on how fast a transfer ought + /// to be - an original-quality video over a slow uplink legitimately takes + /// a long time, and the adapters that do carry request timeouts (only S3, + /// at S3ApiClient.defaultUploadTimeout) already police that layer. Its one + /// job is to keep a transfer that will never come back from freezing the + /// whole queue, which is what happened in issue #1270. + static const Duration defaultEntryBudget = Duration(minutes: 30); + + /// How long the drain waits on the preflight before giving up on it. + /// + /// Much shorter than [defaultEntryBudget] because the work is much + /// smaller: one GET of smv1/store.json. It runs before EVERY entry, so a + /// stall here wedges the drain without a single row being touched. + static const Duration defaultPreflightBudget = Duration(seconds: 30); + + final Duration _entryBudget; + final Duration _preflightBudget; + final _log = LoggerService.forClass(MediaStoreWorker); bool _running = false; bool _disposed = false; @@ -100,10 +124,12 @@ class MediaStoreWorker { await _queue.defer(entry.id, DateTime.now().add(deferWindow)); continue; } - await deleteProcessor.process(entry); + await _withinBudget(entry, () => deleteProcessor.process(entry)); continue; } - await _pipeline.process(entry); + await _withinBudget(entry, () async { + await _pipeline.process(entry); + }); } } finally { _running = false; @@ -111,6 +137,40 @@ class MediaStoreWorker { } } + /// Runs one entry's processing under [_entryBudget], moving on rather than + /// waiting forever (issue #1270). + /// + /// The budget stops the drain WAITING; it does not stop the transfer. Dart + /// cannot cancel a Future, so [work] keeps running and still owns its queue + /// row - which is what makes moving on safe. The row it left in + /// 'transferring' is invisible to [MediaTransferQueueRepository.nextPending] + /// until that call finally settles it, and every staging path is minted per + /// call ([MediaCacheStore.stagingFile]), so the entries that follow cannot + /// collide with the one still in flight. + /// + /// The deferral is load-bearing in exactly one case: a hang BEFORE + /// markTransferring (a stalled queue write, or a processor that never + /// reaches it) leaves the row 'pending' and re-selectable, and without a + /// future nextAttemptAt the loop would pick it straight back up and spin. + /// On the ordinary 'transferring' row the write is inert. [defer] is the + /// right verb either way: a budget expiry is a postponement, not a failed + /// attempt - the transfer may yet succeed, so it must not burn one of the + /// five attempts markFailed counts. + Future _withinBudget( + MediaTransferQueueEntry entry, + Future Function() work, + ) async { + try { + await work().timeout(_entryBudget); + } on TimeoutException { + _log.warning( + 'Transfer entry ${entry.id} (media ${entry.mediaId}) exceeded its ' + '${_entryBudget.inMinutes}m budget; deferring it and draining on', + ); + await _queue.defer(entry.id, DateTime.now().add(deferWindow)); + } + } + /// Whether the drain may proceed. Null preflight admits everything. /// /// A preflight that throws suspends the drain exactly like one that returns @@ -123,6 +183,12 @@ class MediaStoreWorker { /// stop transfers against a store this device may no longer be attached to, /// so "could not verify" must never be treated as "verified". /// + /// A preflight that never answers is the same case, and reaches the same + /// handler: [_preflightBudget] turns the stall into a TimeoutException. + /// Only the S3 adapter carries request timeouts of its own, so on the + /// others this is the sole thing standing between a stalled marker read + /// and a drain that hangs before touching a single row (issue #1270). + /// /// The throw is logged with its error and stack trace, not interpolated into /// the message: catching it is what stops the crash, so the log is now the /// only record of a preflight that keeps failing, and a bare string would @@ -131,7 +197,7 @@ class MediaStoreWorker { final preflight = _preflight; if (preflight == null) return true; try { - if (await preflight()) return true; + if (await preflight().timeout(_preflightBudget)) return true; _log.warning('Media store preflight failed; drain suspended'); } on Object catch (e, stackTrace) { _log.warning( diff --git a/lib/features/media_store/data/media_upload_pipeline.dart b/lib/features/media_store/data/media_upload_pipeline.dart index a8a9bad7cf..c702b4277d 100644 --- a/lib/features/media_store/data/media_upload_pipeline.dart +++ b/lib/features/media_store/data/media_upload_pipeline.dart @@ -286,7 +286,18 @@ class MediaUploadPipeline { return (isOverride || existing == null) ? UploadOutcome.uploaded : UploadOutcome.deduplicated; - } on Exception catch (e, stackTrace) { + } on Object catch (e, stackTrace) { + // Untyped on purpose (issue #1270). process() marks the row + // 'transferring' before it does anything, and nextPending never selects + // that state, so anything that escapes this catch leaves the row + // invisible to the drainer - unretryable and unclearable from the + // Transfers UI - until the next launch's reclaim pass hands it straight + // back to whatever raised it. Errors are not hypothetical on this path: + // an uninitialized singleton raises StateError (the same reasoning + // MediaDeletionCoordinator._delete documents), and staging a large + // original can raise OutOfMemoryError. Neither is an Exception, and + // markFailed's backoff is a far better answer to both than a row that + // wedges the queue head. _log.error( 'Upload failed for media ${entry.mediaId}', error: e, diff --git a/lib/features/media_store/presentation/pages/transfers_page.dart b/lib/features/media_store/presentation/pages/transfers_page.dart index bcfb52fb6b..c1aad545a5 100644 --- a/lib/features/media_store/presentation/pages/transfers_page.dart +++ b/lib/features/media_store/presentation/pages/transfers_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; @@ -33,6 +35,26 @@ class _TransfersPageState extends ConsumerState { bool get _isSelectionMode => _selection.value.isActive; Set get _selectedIds => _selection.value.checkedIds; + @override + void initState() { + super.initState(); + // Opening this page resumes the queue (issue #1270). + // + // The dashboard's "N uploads pending" chip pushes straight here, and this + // route is a plain `builder` nested under media-storage, so + // MediaStoragePage - whose build resolves the runtime, and which is + // therefore the app's only reliable drain trigger - never runs on the way + // in. Someone arriving to ask why nothing is uploading was shown the + // stuck rows and nothing else. + // + // Resolving the runtime IS the kick: see the unawaited worker.drain() at + // the end of mediaStoreRuntimeProvider. Deliberately a read from + // initState rather than a watch in build whose value is thrown away - the + // list's rebuilds have nothing to do with the store's lifecycle, and the + // intent should not have to be inferred from an unused expression. + unawaited(ref.read(mediaStoreRuntimeProvider.future)); + } + /// Retry is safe only for a terminally failed entry. A `transferring` row /// must never be retried: the worker still holds it and a requeue would /// upload the same asset twice. diff --git a/lib/features/media_store/presentation/providers/media_store_providers.dart b/lib/features/media_store/presentation/providers/media_store_providers.dart index 9621ed7e09..7124fdaf78 100644 --- a/lib/features/media_store/presentation/providers/media_store_providers.dart +++ b/lib/features/media_store/presentation/providers/media_store_providers.dart @@ -158,6 +158,51 @@ final mediaVerifyRunnerProvider = }; }); +/// Resumes an outstanding transfer queue at app launch and on app resume +/// (issue #1270). +/// +/// Every other drain trigger is downstream of [mediaStoreRuntimeProvider] +/// already existing: the runtime is what kicks the first drain, subscribes to +/// connectivity changes, and lets the worker arm its retry wakeup. Nothing in +/// the launch path ever built it, and the display surfaces cannot stand in for +/// one - the media grid only reaches the store for a row that is already +/// backed up (`MediaItemView`'s storeConfirmed gate), which on a device that +/// has never finished an upload is never true. So a queue that stopped for any +/// reason - the app quit mid-import, a moment offline, a policy hold - had no +/// way back, and the reporter's 196 rows survived every restart untouched. +/// +/// Two cheap guards run before anything expensive, because building the +/// runtime opens the keychain and reads the store marker out of the bucket: +/// [mediaStoreAttachedProvider] is one SharedPreferences read (and is +/// documented never to error, which is exactly why it exists), and +/// [MediaTransferQueueRepository.nextPending] is one indexed local read that +/// means precisely "there is work a drain could take right now". A queue +/// holding only deferred rows is left to the worker's own wakeup timer. +/// +/// Contains its own failures rather than propagating them: both call sites are +/// fire-and-forget, so an escaping throw would land in the zone handler with +/// nothing to catch it - the shape of #942. +// no-tick: the value is a CLOSURE, not a query result. Every read happens +// inside it at call time via ref.read, so there is no cached row to go stale. +final mediaTransferResumeProvider = Provider Function()>((ref) { + return () async { + try { + if (!await ref.read(mediaStoreAttachedProvider.future)) return; + final queue = ref.read(mediaTransferQueueRepositoryProvider); + if (await queue.nextPending(DateTime.now()) == null) return; + // Building the runtime is the kick: see the unawaited drain at the end + // of mediaStoreRuntimeProvider. + await ref.read(mediaStoreRuntimeProvider.future); + } on Object catch (e, stackTrace) { + LoggerService.forClass(MediaStoreWorker).warning( + 'Could not resume media transfers', + error: e, + stackTrace: stackTrace, + ); + } + }; +}); + final mediaBackfillServiceProvider = Provider( (ref) => MediaBackfillService( mediaRepository: ref.watch(mediaRepositoryProvider), diff --git a/test/features/media_store/media_store_worker_budget_test.dart b/test/features/media_store/media_store_worker_budget_test.dart new file mode 100644 index 0000000000..5f1818059b --- /dev/null +++ b/test/features/media_store/media_store_worker_budget_test.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/database/local_cache_database.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/media_source_resolver_registry.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; +import 'package:submersion/features/media_store/data/media_delete_processor.dart'; +import 'package:submersion/features/media_store/data/media_store_worker.dart'; +import 'package:submersion/features/media_store/data/media_transfer_queue_repository.dart'; +import 'package:submersion/features/media_store/data/media_upload_pipeline.dart'; + +import '../../helpers/in_memory_media_object_store.dart'; +import '../../helpers/test_database.dart'; + +/// Processes every entry except the ones named in [hangOn], which are left +/// unresolved for the life of the test - the shape of a transfer that never +/// comes back rather than one that fails. +class _HangingPipeline extends MediaUploadPipeline { + _HangingPipeline({ + required this.queueRef, + required this.hangOn, + required super.mediaRepository, + required super.queue, + required super.store, + required super.registry, + required super.cache, + this.markTransferringFirst = true, + }); + + final MediaTransferQueueRepository queueRef; + final Set hangOn; + + /// Whether a hanging entry gets as far as `markTransferring`. The real + /// pipeline always does; false models a hang in the queue write itself, + /// which leaves the row 'pending' and therefore re-selectable. + final bool markTransferringFirst; + + final processed = []; + final _stuck = >[]; + + /// Releases every parked call so the test ends with nothing in flight. + void releaseAll() { + for (final completer in _stuck) { + if (!completer.isCompleted) completer.complete(UploadOutcome.failed); + } + } + + @override + Future process(MediaTransferQueueEntry entry) async { + if (hangOn.contains(entry.mediaId)) { + if (markTransferringFirst) await queueRef.markTransferring(entry.id); + final completer = Completer(); + _stuck.add(completer); + return completer.future; + } + processed.add(entry.mediaId); + await queueRef.markDone(entry.id); + return UploadOutcome.uploaded; + } +} + +class _HangingDeleteProcessor extends MediaDeleteProcessor { + _HangingDeleteProcessor({ + required super.queue, + required super.store, + required super.mediaRepository, + }); + + final _stuck = >[]; + + void releaseAll() { + for (final completer in _stuck) { + if (!completer.isCompleted) completer.complete(); + } + } + + @override + Future process(MediaTransferQueueEntry entry) { + final completer = Completer(); + _stuck.add(completer); + return completer.future; + } +} + +void main() { + late MediaRepository mediaRepository; + late LocalCacheDatabase cacheDb; + late Directory root; + late MediaTransferQueueRepository queue; + + /// Short enough that a test waits it out in real time. The worker's default + /// is minutes; the seam exists so the budget is assertable at all. + const budget = Duration(milliseconds: 30); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await setUpTestDatabase(); + mediaRepository = MediaRepository(); + cacheDb = LocalCacheDatabase(NativeDatabase.memory()); + root = await Directory.systemTemp.createTemp('worker_budget'); + queue = MediaTransferQueueRepository(database: cacheDb); + }); + + tearDown(() async { + await cacheDb.close(); + if (root.existsSync()) await root.delete(recursive: true); + await tearDownTestDatabase(); + }); + + _HangingPipeline buildPipeline({ + required Set hangOn, + bool markTransferringFirst = true, + }) { + return _HangingPipeline( + queueRef: queue, + hangOn: hangOn, + markTransferringFirst: markTransferringFirst, + mediaRepository: mediaRepository, + queue: queue, + store: InMemoryMediaObjectStore(), + registry: MediaSourceResolverRegistry({}), + cache: MediaCacheStore(database: cacheDb, root: root), + ); + } + + // Issue #1270: the drain is sequential and single-flight, so an upload that + // never returns holds _running forever and every later kick - connectivity, + // enqueue, the retry wakeup - becomes a no-op. One unreachable item froze + // the whole queue, and the next launch's reclaim handed the same row back to + // the same wedge. + test('an entry that never completes does not freeze the rest of the ' + 'queue', () async { + await queue.enqueueUpload(mediaId: 'stuck'); + await queue.enqueueUpload(mediaId: 'healthy'); + final pipeline = buildPipeline(hangOn: {'stuck'}); + addTearDown(pipeline.releaseAll); + final worker = MediaStoreWorker( + queue: queue, + pipeline: pipeline, + entryBudget: budget, + ); + addTearDown(worker.dispose); + + await worker.drain(); + + expect(pipeline.processed, ['healthy']); + }); + + // The budget stops the drain WAITING; it cannot stop the upload, because + // Dart cannot cancel a Future. A hang before markTransferring therefore + // leaves the row 'pending' and re-selectable, and without the deferral the + // loop would pick it straight back up and spin. + test('a budgeted-out entry is deferred so the drain cannot spin on ' + 'it', () async { + await queue.enqueueUpload(mediaId: 'stuck'); + final pipeline = buildPipeline( + hangOn: {'stuck'}, + markTransferringFirst: false, + ); + addTearDown(pipeline.releaseAll); + final worker = MediaStoreWorker( + queue: queue, + pipeline: pipeline, + entryBudget: budget, + ); + addTearDown(worker.dispose); + + await worker.drain(); + + final rows = await queue.allForTesting(); + expect(rows.single.state, 'pending'); + expect(rows.single.nextAttemptAt, isNotNull); + // A budget expiry is a postponement, not a failed attempt: the entry may + // still be uploading, and burning one of its five attempts would retire a + // healthy-but-slow item. + expect(rows.single.attempts, 0); + }); + + // The preflight runs before every entry and reads smv1/store.json out of the + // bucket. Only the S3 adapter carries HTTP timeouts of its own, so on the + // others a stalled read wedges the drain before any row is touched. + test('a preflight that never answers suspends the drain instead of ' + 'hanging it', () async { + await queue.enqueueUpload(mediaId: 'healthy'); + final pipeline = buildPipeline(hangOn: const {}); + final worker = MediaStoreWorker( + queue: queue, + pipeline: pipeline, + preflight: () => Completer().future, + preflightBudget: budget, + ); + addTearDown(worker.dispose); + + await expectLater(worker.drain(), completes); + expect(pipeline.processed, isEmpty); + }); + + // Deletes share the drain, so they wedge it the same way an upload does. + test('a delete entry that never completes does not freeze the ' + 'queue', () async { + await queue.enqueueDelete( + mediaId: 'gone', + contentHash: 'abc', + originalExt: 'jpg', + renditionExt: 'jpg', + ); + await queue.enqueueUpload(mediaId: 'healthy'); + final pipeline = buildPipeline(hangOn: const {}); + final deleteProcessor = _HangingDeleteProcessor( + queue: queue, + store: InMemoryMediaObjectStore(), + mediaRepository: mediaRepository, + ); + addTearDown(deleteProcessor.releaseAll); + final worker = MediaStoreWorker( + queue: queue, + pipeline: pipeline, + deleteProcessor: deleteProcessor, + entryBudget: budget, + ); + addTearDown(worker.dispose); + + await worker.drain(); + + expect(pipeline.processed, ['healthy']); + }); +} diff --git a/test/features/media_store/media_transfer_resume_provider_test.dart b/test/features/media_store/media_transfer_resume_provider_test.dart new file mode 100644 index 0000000000..1eb60f7a9c --- /dev/null +++ b/test/features/media_store/media_transfer_resume_provider_test.dart @@ -0,0 +1,109 @@ +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/database/local_cache_database.dart'; +import 'package:submersion/features/media_store/data/media_transfer_queue_repository.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; + +void main() { + late LocalCacheDatabase db; + late MediaTransferQueueRepository queue; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + db = LocalCacheDatabase(NativeDatabase.memory()); + queue = MediaTransferQueueRepository(database: db); + }); + + tearDown(() => db.close()); + + /// Counts runtime builds without constructing a real store. Building the + /// real runtime is what kicks the drain, attaches the connectivity + /// subscription, and arms the retry wakeup, so "was it built?" is the whole + /// question this provider answers. + ({ProviderContainer container, List builds}) buildContainer({ + required bool attached, + }) { + final builds = []; + final container = ProviderContainer( + overrides: [ + mediaTransferQueueRepositoryProvider.overrideWithValue(queue), + mediaStoreAttachedProvider.overrideWith((ref) async => attached), + mediaStoreRuntimeProvider.overrideWith((ref) async { + builds.add(builds.length); + return null; + }), + ], + ); + addTearDown(container.dispose); + return (container: container, builds: builds); + } + + // Issue #1270: nothing in main.dart, app.dart, or startup_page.dart ever + // reads mediaStoreRuntimeProvider, so a queue left over from a previous + // session had no trigger at all - the reporter's 196 rows survived every + // restart untouched. + test('a device with due work builds the runtime, which drains it', () async { + await queue.enqueueUpload(mediaId: 'm1'); + final harness = buildContainer(attached: true); + + await harness.container.read(mediaTransferResumeProvider)(); + + expect(harness.builds, hasLength(1)); + }); + + // Building the runtime opens the keychain and reads the store marker out of + // the bucket. That is far too expensive to pay on every launch and resume of + // a device that has nothing to transfer. + test('an empty queue does not build the runtime', () async { + final harness = buildContainer(attached: true); + + await harness.container.read(mediaTransferResumeProvider)(); + + expect(harness.builds, isEmpty); + }); + + // The attach check is one SharedPreferences read and short-circuits before + // the queue is even opened, which is why mediaStoreAttachedProvider exists. + test('a device with no store attached does not build the runtime', () async { + await queue.enqueueUpload(mediaId: 'm1'); + final harness = buildContainer(attached: false); + + await harness.container.read(mediaTransferResumeProvider)(); + + expect(harness.builds, isEmpty); + }); + + // A row parked behind markFailed's backoff (up to 25 hours) is not due, so + // there is nothing for a drain to take. The worker's own wakeup timer owns + // that case once the runtime exists. + test('a queue holding only deferred rows does not build the ' + 'runtime', () async { + await queue.enqueueUpload(mediaId: 'm1'); + final entry = (await queue.nextPending(DateTime.now()))!; + await queue.defer(entry.id, DateTime.now().add(const Duration(hours: 25))); + final harness = buildContainer(attached: true); + + await harness.container.read(mediaTransferResumeProvider)(); + + expect(harness.builds, isEmpty); + }); + + // Both call sites are fire-and-forget, so an unusable local cache database + // (StateError, an Error rather than an Exception) must not escape into the + // zone handler the way the preflight throw did in #942. + test('a failure resuming transfers is contained, not thrown', () async { + final container = ProviderContainer( + overrides: [ + mediaStoreAttachedProvider.overrideWith((ref) async => true), + mediaTransferQueueRepositoryProvider.overrideWith( + (ref) => MediaTransferQueueRepository(), + ), + ], + ); + addTearDown(container.dispose); + + await expectLater(container.read(mediaTransferResumeProvider)(), completes); + }); +} diff --git a/test/features/media_store/media_upload_pipeline_test.dart b/test/features/media_store/media_upload_pipeline_test.dart index 86a7107a6d..a119792087 100644 --- a/test/features/media_store/media_upload_pipeline_test.dart +++ b/test/features/media_store/media_upload_pipeline_test.dart @@ -46,6 +46,24 @@ class _PutThrowsStore extends InMemoryMediaObjectStore { } } +/// A store whose uploads fail with an Error rather than an Exception. +/// StateError is what an uninitialized singleton throws (the pattern +/// MediaDeletionCoordinator already documents), and a staging read of a large +/// original can raise OutOfMemoryError; neither is an Exception. +class _PutThrowsErrorStore extends InMemoryMediaObjectStore { + @override + Future putFile( + String key, + File source, { + required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }) async { + throw StateError('LocalCacheDatabaseService is not initialized'); + } +} + class _FakeLocalFileResolver implements MediaSourceResolver { _FakeLocalFileResolver(this.data); @@ -441,6 +459,38 @@ void main() { expect(failingStore.objects, isEmpty); }); + // Issue #1270: process() marks the row 'transferring' before it does any + // work, and nextPending deliberately never selects that state. An Error + // escaping the catch therefore left the row invisible to the drainer for + // the rest of the process - unretryable (failed-only) and unclearable + // (done-only) from the Transfers UI - until the next launch's reclaim pass + // handed it back to whatever raised the Error in the first place. + test('an upload Error fails the row rather than stranding it in ' + 'transferring', () async { + final erroringStore = _PutThrowsErrorStore(); + final registry = MediaSourceResolverRegistry({ + MediaSourceType.localFile: resolver, + }); + final erroringPipeline = MediaUploadPipeline( + mediaRepository: mediaRepository, + queue: queue, + store: erroringStore, + registry: registry, + cache: cache, + thumbnails: ThumbnailGenerator(registry: registry, cache: cache), + now: () => DateTime(2026, 7, 10, 12), + ); + + await enqueueLocalFileItem(bytes: [1, 2, 3], name: 'error.jpg'); + final entry = (await queue.nextPending(DateTime.now()))!; + + expect(await erroringPipeline.process(entry), UploadOutcome.failed); + final row = (await queue.allForTesting()).single; + expect(row.state, 'pending', reason: 'the drainer must see it again'); + expect(row.attempts, 1); + expect(row.errorMessage, contains('not initialized')); + }); + group('serviceConnector rows', () { late _FakeLocalFileResolver connectorResolver; late MediaUploadPipeline connectorPipeline; diff --git a/test/features/media_store/transfers_page_test.dart b/test/features/media_store/transfers_page_test.dart index d1a8ad37c7..69ad3cc8b8 100644 --- a/test/features/media_store/transfers_page_test.dart +++ b/test/features/media_store/transfers_page_test.dart @@ -419,4 +419,42 @@ void main() { await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); }); + + // Issue #1270: the dashboard's "N uploads pending" chip pushes straight + // here, and go_router builds this route with a plain `builder` nested under + // media-storage, so MediaStoragePage - the one screen whose build resolves + // the runtime, and therefore the app's only reliable drain trigger - never + // runs on the way in. Opening Transfers showed the stuck rows without doing + // anything about them, which is precisely what the reporter expected it to + // fix. Resolving the runtime here kicks the drain (see the unawaited + // worker.drain() at the end of mediaStoreRuntimeProvider). + testWidgets('opening the page resolves the runtime, which drains the queue', ( + tester, + ) async { + var builds = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + mediaTransferQueueRepositoryProvider.overrideWithValue(repo), + localAssetCacheRepositoryProvider.overrideWithValue(assetCache), + mediaTransferEntriesProvider.overrideWith( + (ref) => Stream.value(const []), + ), + mediaStoreRuntimeProvider.overrideWith((ref) async { + builds++; + return null; + }), + ], + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TransfersPage(), + ), + ), + ); + await pumpRoute(tester); + + expect(builds, 1); + }); } From 59543735fa5102a802ed56d90be66d7da6ef77c8 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:29:23 -0400 Subject: [PATCH 031/122] test(certifications): cover the remaining surfaces the title fix changed Patch coverage was 81.82%: the summary preview and the buddy detail cert list each had one cold added line, and the picker was absent from the local lcov entirely because nothing in the certifications or buddies test dirs loads it. CI does load it, through course_edit_page, so its added lines count there. Adds tests for the three surfaces the fix changed but did not yet verify: - certification_summary_widget_test.dart: the recent-certification preview. Reads the two lines off the ListTile rather than by text, because the leading avatar renders the agency too and a bare match on "PADI" cannot tell the subtitle from the badge. - certification_picker_test.dart: the collapsed field and the sheet. CertificationPickerSheet is public, so it renders directly instead of driving a modal route. Pins Intl.defaultLocale for the dated assertion, same as the list tile tests. - buddy_detail_cert_subtitle_test.dart: the buddy detail cert list. Patch coverage is now 100% (18/18 instrumented added lines). The picker's semantics label is read off the Semantics widget rather than with find.bySemanticsLabel, following certification_ecard_test.dart. That wrapper merges with the tile's own text instead of replacing it, so the rendered node is the declared label followed by the visible title and subtitle, despite the comment there claiming it replaces them. Pre-existing, not touched here. --- .../buddy_detail_cert_subtitle_test.dart | 113 +++++++++++ .../widgets/certification_picker_test.dart | 192 ++++++++++++++++++ .../certification_summary_widget_test.dart | 103 ++++++++++ 3 files changed, 408 insertions(+) create mode 100644 test/features/buddies/presentation/pages/buddy_detail_cert_subtitle_test.dart create mode 100644 test/features/certifications/presentation/widgets/certification_picker_test.dart create mode 100644 test/features/certifications/presentation/widgets/certification_summary_widget_test.dart diff --git a/test/features/buddies/presentation/pages/buddy_detail_cert_subtitle_test.dart b/test/features/buddies/presentation/pages/buddy_detail_cert_subtitle_test.dart new file mode 100644 index 0000000000..7ea3a8f176 --- /dev/null +++ b/test/features/buddies/presentation/pages/buddy_detail_cert_subtitle_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/buddies/presentation/pages/buddy_detail_page.dart'; +import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +final _buddy = Buddy( + id: 'buddy-1', + name: 'Jane Doe', + notes: '', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), +); + +Certification _makeCert({ + required String name, + CertificationLevel? level, + CertificationAgency agency = CertificationAgency.padi, +}) { + return Certification( + id: 'c1', + name: name, + agency: agency, + level: level, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); +} + +Future _pump(WidgetTester tester, Certification cert) async { + final overrides = await getBaseOverrides(); + + final router = GoRouter( + initialLocation: '/buddies/buddy-1', + routes: [ + GoRoute( + path: '/buddies/:id', + builder: (context, state) => + BuddyDetailPage(buddyId: state.pathParameters['id']!), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + buddyByIdProvider(_buddy.id).overrideWith((ref) async => _buddy), + buddyCertificationsProvider( + _buddy.id, + ).overrideWith((ref) async => [cert]), + ], + child: MaterialApp.router( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// The certification tile's subtitle, read off the [ListTile] so it cannot be +/// confused with the identically-worded text elsewhere on the page. +String _certSubtitle(WidgetTester tester) { + final tile = tester.widget( + find.ancestor( + of: find.byIcon(Icons.card_membership), + matching: find.byType(ListTile), + ), + ); + return (tile.subtitle! as Text).data!; +} + +void main() { + group('buddy certification list', () { + testWidgets('a custom name keeps the certification in the subtitle', ( + tester, + ) async { + // Issue #1265: the title is the custom name, so the subtitle is the only + // place left for the level. + await _pump( + tester, + _makeCert(name: 'Bill Ansell', level: CertificationLevel.diveMaster), + ); + + expect(find.text('Bill Ansell'), findsOneWidget); + expect(_certSubtitle(tester), 'PADI - Divemaster'); + }); + + testWidgets('a derived title does not repeat the level in the subtitle', ( + tester, + ) async { + await _pump( + tester, + _makeCert(name: '', level: CertificationLevel.diveMaster), + ); + + expect(find.text('Divemaster'), findsOneWidget); + expect(_certSubtitle(tester), 'PADI'); + }); + }); +} diff --git a/test/features/certifications/presentation/widgets/certification_picker_test.dart b/test/features/certifications/presentation/widgets/certification_picker_test.dart new file mode 100644 index 0000000000..32dc6a7998 --- /dev/null +++ b/test/features/certifications/presentation/widgets/certification_picker_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; +import 'package:submersion/features/certifications/presentation/widgets/certification_picker.dart'; + +import '../../../../helpers/test_app.dart'; + +class _MockCertListNotifier + extends StateNotifier>> + implements CertificationListNotifier { + _MockCertListNotifier(List certs) + : super(AsyncValue.data(certs)); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +final _now = DateTime(2026, 8, 24); + +Certification _makeCert({ + required String name, + CertificationLevel? level, + DateTime? issueDate, + CertificationAgency agency = CertificationAgency.padi, +}) { + return Certification( + id: 'c1', + name: name, + agency: agency, + level: level, + issueDate: issueDate, + createdAt: _now, + updatedAt: _now, + ); +} + +/// The collapsed picker field, which shows the current selection. +Future _pumpField(WidgetTester tester, Certification? selected) async { + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + certificationListNotifierProvider.overrideWith( + (ref) => _MockCertListNotifier(const []), + ), + ], + child: CertificationPicker( + selectedCertification: selected, + onCertificationSelected: (_) {}, + ), + ), + ); + await tester.pump(); +} + +/// The sheet body directly, rather than through showModalBottomSheet: it is a +/// public widget, so rendering it avoids driving a modal route just to read +/// two lines of text off a tile. +Future _pumpSheet(WidgetTester tester, List certs) async { + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + certificationListNotifierProvider.overrideWith( + (ref) => _MockCertListNotifier(certs), + ), + ], + child: CertificationPickerSheet( + scrollController: ScrollController(), + selectedCertification: null, + onCertificationSelected: (_) {}, + ), + ), + ); + await tester.pump(); +} + +String _subtitleOf(WidgetTester tester, Finder tile) => + ((tester.widget(tile)).subtitle! as Text).data!; + +/// The label the sheet's [Semantics] wrapper declares for a tile. +/// +/// Read off the widget rather than queried with `find.bySemanticsLabel`, +/// following the precedent in certification_ecard_test.dart: this wrapper +/// merges with the tile's own text instead of replacing it, so the rendered +/// node is the declared label followed by the visible title and subtitle. +String _tileSemanticsLabel(WidgetTester tester) { + final candidates = find.ancestor( + of: find.byType(ListTile), + matching: find.byType(Semantics), + ); + for (final element in candidates.evaluate()) { + final label = (element.widget as Semantics).properties.label; + if (label != null && label.isNotEmpty) return label; + } + return ''; +} + +void main() { + // The sheet subtitle dates itself with DateFormat.yMMMd(), which resolves + // against Intl.defaultLocale (a process global) rather than the + // MaterialApp.locale the harness passes. Pin it so the "Aug 24, 2026" + // assertion states its real dependency, and restore it afterwards because + // the global leaks across tests in the same isolate. + String? previousLocale; + setUp(() { + previousLocale = Intl.defaultLocale; + Intl.defaultLocale = 'en'; + }); + tearDown(() => Intl.defaultLocale = previousLocale); + + group('collapsed picker field', () { + testWidgets('a custom name keeps the certification in the subtitle', ( + tester, + ) async { + // Issue #1265: the title is the custom name, so the subtitle is the only + // place left for the level. + await _pumpField( + tester, + _makeCert(name: 'Bill Ansell', level: CertificationLevel.diveMaster), + ); + + expect(find.text('Bill Ansell'), findsOneWidget); + expect(_subtitleOf(tester, find.byType(ListTile)), 'PADI - Divemaster'); + }); + + testWidgets('a derived title does not repeat the level', (tester) async { + await _pumpField( + tester, + _makeCert(name: '', level: CertificationLevel.diveMaster), + ); + + expect(find.text('Divemaster'), findsOneWidget); + expect(_subtitleOf(tester, find.byType(ListTile)), 'PADI'); + }); + }); + + group('picker sheet', () { + testWidgets('a custom name keeps the certification in the subtitle', ( + tester, + ) async { + await _pumpSheet(tester, [ + _makeCert( + name: 'Bill Ansell', + level: CertificationLevel.diveMaster, + issueDate: DateTime(2026, 8, 24), + ), + ]); + + expect( + _subtitleOf(tester, find.byType(ListTile)), + 'PADI - Divemaster - Aug 24, 2026', + ); + }); + + testWidgets('a certification with no issue date omits the date', ( + tester, + ) async { + await _pumpSheet(tester, [ + _makeCert(name: 'Bill Ansell', level: CertificationLevel.diveMaster), + ]); + + expect(_subtitleOf(tester, find.byType(ListTile)), 'PADI - Divemaster'); + }); + + testWidgets('the accessibility label names the certification too', ( + tester, + ) async { + await _pumpSheet(tester, [ + _makeCert(name: 'Bill Ansell', level: CertificationLevel.diveMaster), + ]); + + // A screen reader must hear the level even when a custom name owns the + // title, since the title alone no longer carries it. + expect(_tileSemanticsLabel(tester), 'PADI Bill Ansell, Divemaster'); + }); + + testWidgets('a derived title does not repeat the level', (tester) async { + await _pumpSheet(tester, [ + _makeCert(name: '', level: CertificationLevel.diveMaster), + ]); + + expect(_subtitleOf(tester, find.byType(ListTile)), 'PADI'); + // The title is already the level, so the label says it once, not twice. + expect(_tileSemanticsLabel(tester), 'PADI Divemaster'); + }); + }); +} diff --git a/test/features/certifications/presentation/widgets/certification_summary_widget_test.dart b/test/features/certifications/presentation/widgets/certification_summary_widget_test.dart new file mode 100644 index 0000000000..9b0c8ebbfc --- /dev/null +++ b/test/features/certifications/presentation/widgets/certification_summary_widget_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; +import 'package:submersion/features/certifications/presentation/widgets/certification_summary_widget.dart'; + +import '../../../../helpers/test_app.dart'; + +class _MockCertListNotifier + extends StateNotifier>> + implements CertificationListNotifier { + _MockCertListNotifier(List certs) + : super(AsyncValue.data(certs)); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +final _now = DateTime(2026, 8, 24); + +Certification _makeCert({ + required String name, + CertificationLevel? level, + CertificationAgency agency = CertificationAgency.padi, +}) { + return Certification( + id: 'c1', + name: name, + agency: agency, + level: level, + createdAt: _now, + updatedAt: _now, + ); +} + +Future _pump(WidgetTester tester, Certification cert) async { + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + certificationListNotifierProvider.overrideWith( + (ref) => _MockCertListNotifier([cert]), + ), + ], + child: const CertificationSummaryWidget(), + ), + ); + await tester.pump(); +} + +/// The preview tile's two lines. Read off the [ListTile] rather than via +/// `find.text` because the leading avatar also renders the agency, so a bare +/// text match on "PADI" cannot tell the subtitle from the badge. +({String title, String subtitle}) _tileLines(WidgetTester tester) { + final tile = tester.widget(find.byType(ListTile)); + return ( + title: (tile.title! as Text).data!, + subtitle: (tile.subtitle! as Text).data!, + ); +} + +void main() { + group('recent certification preview', () { + testWidgets('a custom name keeps the certification in the subtitle', ( + tester, + ) async { + // Issue #1265: the title is the custom name, so the subtitle is the only + // place left for the level. + await _pump( + tester, + _makeCert(name: 'Bill Ansell', level: CertificationLevel.diveMaster), + ); + + expect(_tileLines(tester), ( + title: 'Bill Ansell', + subtitle: 'PADI - Divemaster', + )); + }); + + testWidgets('a derived title does not repeat the level in the subtitle', ( + tester, + ) async { + await _pump( + tester, + _makeCert(name: '', level: CertificationLevel.diveMaster), + ); + + // The title already says "Divemaster"; the subtitle must not say it + // again, which is the duplication the title helper exists to remove. + expect(_tileLines(tester), (title: 'Divemaster', subtitle: 'PADI')); + }); + + testWidgets('a custom name with no level shows the agency alone', ( + tester, + ) async { + await _pump(tester, _makeCert(name: 'DAN Insurance')); + + expect(_tileLines(tester), (title: 'DAN Insurance', subtitle: 'PADI')); + }); + }); +} From 368829a1b1153c685eaa21e1551540ba50b62d3b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:33:20 -0400 Subject: [PATCH 032/122] fix(cloud): give the Dropbox and Google Drive transports request deadlines S3ApiClient was the only cloud transport with request deadlines. Dropbox and Google Drive both fell back to a bare http.Client(), which on Dart IO leaves HttpClient.connectionTimeout null and adds no response or read deadline at all: a socket that never connects waits on the OS default, and one that connects and then wedges parks its request forever. Add TimeoutHttpClient, an http.Client decorator that bounds the response and the body-idle gap, with an overSockets constructor that also bounds the TCP connect. The four deadlines are the ones S3ApiClient settled on in #942. A request takes the generous upload deadline by declared body size, falling back to the HTTP method when the length is unknown: googleapis' RequestImpl never sets contentLength, upload or not, so a size-only rule would have put every Drive upload on the 30s response deadline. Installed at four seams: DropboxApiClient (sync and media), DropboxAuthManager (its token refresh is awaited inside the send loop), DesktopOAuthAuthenticator (under the refreshing client) and GoogleSignInAuthenticator (around the plugin-built client, since its socket layer is not ours to configure). Both Google Drive paths draw their transport from the authenticator, so GoogleDriveMediaObjectStore needs no change. Fixes #1279. --- ...-25-cloud-http-request-deadlines-design.md | 203 ++++++++++++++++ .../dropbox/dropbox_api_client.dart | 14 +- .../dropbox/dropbox_auth_manager.dart | 14 +- .../desktop_oauth_authenticator.dart | 12 +- .../google_sign_in_authenticator.dart | 14 +- .../services/cloud_storage/http_timeouts.dart | 141 +++++++++++ .../dropbox_api_client_timeout_test.dart | 107 ++++++++ .../dropbox/dropbox_auth_manager_test.dart | 17 ++ .../desktop_oauth_authenticator_test.dart | 29 +++ .../google_sign_in_authenticator_test.dart | 14 ++ .../cloud_storage/http_timeouts_test.dart | 229 ++++++++++++++++++ 11 files changed, 788 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-25-cloud-http-request-deadlines-design.md create mode 100644 lib/core/services/cloud_storage/http_timeouts.dart create mode 100644 test/core/services/cloud_storage/dropbox/dropbox_api_client_timeout_test.dart create mode 100644 test/core/services/cloud_storage/http_timeouts_test.dart diff --git a/docs/superpowers/specs/2026-08-25-cloud-http-request-deadlines-design.md b/docs/superpowers/specs/2026-08-25-cloud-http-request-deadlines-design.md new file mode 100644 index 0000000000..a885332fe7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-cloud-http-request-deadlines-design.md @@ -0,0 +1,203 @@ +# Cloud HTTP Request Deadlines: Design + +**Status:** approved 2026-08-25 +**Issue:** #1279 +**Branch:** `worktree-1279-cloud-http-timeouts` +**Supersedes nothing.** Generalises the deadlines `S3ApiClient` gained in +#1175/#942 to the other two cloud transports. + +## Problem + +`S3ApiClient` is the only cloud transport in the app with request deadlines. +The Dropbox and Google Drive clients both fall back to a bare `http.Client()`, +which on Dart IO means `HttpClient.connectionTimeout == null` and no response +or idle deadline at all. A socket that connects and then wedges, or one that +never connects, waits on the OS default. + +Found while investigating #1270 (media uploads stuck on desktop). Not the root +cause of that issue, but the underlying gap is real and worth closing on its +own. + +## Findings + +Every claim below was verified against `origin/main` at 716d2c597fc. + +**F1. The S3 client gets this right and documents why.** +`s3_api_client.dart:93` wraps `IOClient(HttpClient()..connectionTimeout = +defaultConnectTimeout)`. `:117`, `:121`, `:132` and `:140` define +`defaultConnectTimeout` (15s), `defaultResponseTimeout` (30s), +`defaultUploadTimeout` (10m) and `defaultIdleTimeout` (30s), each with its own +rationale traced back to #942, and `:729` applies the response/upload split on +the send path. + +**F2. Dropbox has none.** `dropbox_api_client.dart:64` is +`_http = httpClient ?? http.Client()`. `grep -n "timeout"` over the file +returns nothing. + +**F3. Dropbox's auth manager has none either, and it is on the same critical +path.** `dropbox_auth_manager.dart:25` is the same fallback. This matters more +than it looks: `dropbox_api_client.dart:307` awaits `_getAccessToken()` +*inside* the send loop, before every request. A wedged token refresh stalls +every Dropbox request behind it exactly like a wedged request would, so fixing +only the API client would leave the hole open. + +**F4. Google Drive has none, on either auth path.** +`google_drive_media_object_store.dart:17` takes an injected `http.Client`. +Both construction sites (`media_store_service.dart:60`, +`google_drive_account_adapter.dart:51`) get it from +`google_drive_storage_provider.dart:108`'s `mediaHttpClient()`, which returns +the authenticator's `authClient`. That is a `googleapis_auth` refreshing +client over a plain `http.Client()` in both implementations +(`desktop_oauth_authenticator.dart:60`, `google_sign_in_authenticator.dart:113`). +`grep -n "timeout" lib/core/services/media_store/*.dart` returns nothing. + +**F5. The same client backs Drive *sync*, not just media.** +`google_drive_storage_provider.dart:130` builds `drive.DriveApi(client)` from +the same `authClient`. One wrapper at the authenticator therefore covers sync, +media transfers, and the store marker read below. + +**F6. The store marker read runs before every queue entry, on the same +transport.** `media_store_providers.dart:311` calls +`StoreMarkerStore(store: store).read()` (one GET of `smv1/store.json`, +`store_marker.dart:46`) from the worker's preflight, and +`media_store_worker.dart:78` re-runs that preflight per entry, not once per +drain. + +**F7. There is no containment for a wedged request on `main` today.** +`media_store_worker.dart:66-111` is a sequential `while (true)` loop guarded +by a `_running` single-flight flag, and `await _pipeline.process(entry)` has +no per-entry deadline. A request that never returns therefore blocks the loop +forever *and* leaves `_running` true, so every later `drain()` call returns +immediately. #1279's text refers to a per-entry drain budget from #1270 as +already containing the blast radius; #1270 is still open and that budget is +not on `main`, so the freeze is currently unbounded. + +**F8. googleapis never declares a body length.** +`_discoveryapis_commons-1.0.7/lib/src/request_impl.dart:11-32`: `RequestImpl` +extends `http.BaseRequest`, supplies the body from a `Stream>` in +`finalize()`, and never assigns `contentLength`. It is null for every call the +Drive API makes, upload or not. Any deadline rule keyed purely on declared +body size would put every Drive upload on the short response deadline. + +## Design + +One shared decorator, installed at four seams. No transport re-implements the +policy. + +### Part 1: `TimeoutHttpClient` + +New file `lib/core/services/cloud_storage/http_timeouts.dart`. + +`TimeoutHttpClient` is an `http.BaseClient` decorator over any inner client. +`send` applies a deadline to `_inner.send(request)` (status and headers) and a +second, independent idle deadline to the response body stream, then rebuilds +the `StreamedResponse` around the wrapped stream. `close()` forwards to the +inner client. + +`TimeoutHttpClient.overSockets()` additionally owns its transport: +`IOClient(HttpClient()..connectionTimeout = ...)`. The two halves are +complementary and both are needed. The `send` deadlines bound a socket that +connects and then stalls; `connectionTimeout` bounds one that never connects. + +The four constants are deliberately F1's constants, with F1's reasoning: + +| Deadline | Value | Bounds | +| --- | --- | --- | +| connect | 15s | TCP connect to an unreachable endpoint | +| response | 30s | status + headers on a request with no meaningful body | +| upload | 10m | the same, on a request whose body must be written first | +| idle | 30s | longest gap the response body may go without a byte | + +The idle deadline is a gap, not a total budget: a legitimate 8 MiB download +over a weak link takes minutes and must not be killed, while a connection that +has stopped delivering is dead regardless of how little it had left to send. + +### Part 2: which deadline a request gets + +`Client.send` does not complete until the request body has been written, so +for a PUT the "wait for a response" window contains the whole upload. That is +why the upload deadline exists at all, and why it cannot simply be applied to +everything: a wedged Dropbox RPC would then take ten minutes to fail. + +The classifier is size first, method second: + +1. A declared `contentLength` above `uploadBodyThresholdBytes` (64 KiB) is an + upload. Below it, the request is a control-plane call whose body is written + in one go, so the response deadline is the right one. A Dropbox RPC carries + a few dozen bytes of JSON; the smallest thing the app actually uploads is + an 8 MiB chunk, so 64 KiB separates them with room to spare. +2. When `contentLength` is null the method decides: `GET`, `HEAD` and `DELETE` + get the response deadline, anything else gets the upload deadline. + +Step 2 exists entirely because of F8. Without it every Drive upload would be +cut off at 30s and healthy transfers would start failing. + +### Part 3: the four installation seams + +**Dropbox API client.** `httpClient ?? TimeoutHttpClient.overSockets()`. No +change to `_send`: its existing `on Exception` wrapper already maps the +resulting `TimeoutException` to `CloudStorageException('Could not reach +Dropbox')`, and `DropboxMediaObjectStore` already classifies that message as +transient. This one seam covers Dropbox sync and Dropbox media. + +**Dropbox auth manager.** The same fallback, for F3. + +**Desktop OAuth authenticator.** The default `baseClientFactory` becomes a +timed client. The wrapper goes *underneath* `gauth.autoRefreshingClient` +(`desktop_oauth_authenticator.dart:139`), so it covers every Drive call the +refreshing client makes, its own token refreshes, the consent-flow token +exchange, and revocation. + +**google_sign_in authenticator.** The opposite: the plugin builds the +authorized client over a socket layer the app never sees, so the wrapper goes +on the *outside* of `authorization.authClient(scopes:)`. The field's type +relaxes from `gapis_auth.AuthClient?` to `http.Client?`, which is what the +`GoogleDriveAuthenticator.authClient` contract already exposes and all +downstream consumers already use. + +By F4 and F5 those two authenticator seams are sufficient for Google Drive: +`GoogleDriveMediaObjectStore` needs no change, because its transport is the +authenticator's client at both construction sites. Wrapping it again inside +the store would stack a second idle deadline on the same stream for no gain. + +### Part 4: testability + +Each transport that builds its own default exposes a `@visibleForTesting` +`transport` getter, so a test can assert the fact the issue is actually about +("this client did not fall back to a bare `http.Client()`") rather than +asserting it indirectly through a stall. `TimeoutHttpClient` carries the +`connectTimeout` it configured as a field, null when the inner client came +from the caller, so the same assertion covers the connect half. + +## Out of scope + +**`S3ApiClient` is not routed through the wrapper.** It applies the same four +deadlines inline, interleaved with its SigV4 retry loop, which classifies +`TimeoutException` as a retryable transport fault and replays the request +(possibly against a server-corrected region). Unpicking that is a change to a +well-covered path with no behaviour to gain. The duplication is one `.timeout` +pair, and this design deliberately adopts its constants rather than inventing +new ones. + +**Other bare `http.Client()` users stay as they are:** weather, tides, reef, +bathymetry, the GitHub updater, and the Lightroom client. None sit on the sync +or media transfer paths, so none can wedge a queue. The wrapper is now +available to them. + +**Retry policy is unchanged.** A deadline that fires surfaces through each +client's existing error mapping. Nothing here adds attempts, backoff, or a new +`UnavailableKind`; per the media fetch-budget work, a new unavailability kind +must never reach an orphan path, so introducing one was avoided outright. + +## Product decisions (do not relitigate) + +- **Ten minutes, not one, for uploads.** Killing a transfer that was making + progress is the failure mode that left large first syncs failing nine times + in ten (#942). The deadline exists to bound a genuinely wedged socket, not + to enforce throughput. +- **A 64 KiB threshold, not "any body".** Chosen so control-plane POSTs fail + fast while real chunks get the long clock. It is a classifier, not a limit: + nothing rejects a body for its size. +- **Deadlines live in the transport, not in each call site.** Every call site + that would otherwise need its own `.timeout` is a place a future call site + can forget one. That is exactly how #1279 happened. diff --git a/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart b/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart index 990898ffa3..a2aa2586ff 100644 --- a/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart +++ b/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; /// Account labels from /users/get_current_account. class DropboxAccount { @@ -51,6 +53,11 @@ class DropboxFileMetadata { /// (getMetadata, delete); download throws. /// - insufficient_space -> distinct user-facing message. /// - anything else non-2xx, and transport errors -> wrapped generic. +/// +/// Every request runs under [TimeoutHttpClient]'s deadlines unless the caller +/// injects its own transport: a bare `http.Client()` has none, so a wedged +/// socket used to park a request forever, freezing the sequential media +/// transfer drain that runs through DropboxMediaObjectStore (#1279). class DropboxApiClient { DropboxApiClient({ required Future Function() getAccessToken, @@ -61,7 +68,7 @@ class DropboxApiClient { Future Function(Duration)? wait, }) : _getAccessToken = getAccessToken, _onAccessTokenRejected = onAccessTokenRejected, - _http = httpClient ?? http.Client(), + _http = httpClient ?? TimeoutHttpClient.overSockets(), _wait = wait ?? ((d) => Future.delayed(d)); static final Uri _apiBase = Uri.parse('https://api.dropboxapi.com'); @@ -80,6 +87,11 @@ class DropboxApiClient { final http.Client _http; final Future Function(Duration) _wait; + /// The transport actually in use, so a test can assert that a client nobody + /// handed one to did not fall back to a deadline-free `http.Client()`. + @visibleForTesting + http.Client get transport => _http; + Future upload(String path, Uint8List data) async { if (data.length > chunkedUploadThresholdBytes) { return _uploadChunked(path, data); diff --git a/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart b/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart index 7d27822e9b..79801a7587 100644 --- a/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart +++ b/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart @@ -1,10 +1,12 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_app.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_store.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; import 'package:submersion/core/services/oauth/oauth_pkce.dart'; import 'package:submersion/core/services/logger_service.dart'; @@ -14,6 +16,10 @@ import 'package:submersion/core/services/logger_service.dart'; /// /// The refresh token is the only persisted credential (DropboxAuthStore); /// access tokens (~4 h lifetime) live in memory only. +/// +/// The transport carries [TimeoutHttpClient]'s deadlines: token refresh is +/// awaited inside DropboxApiClient's send loop, so a wedged refresh stalls +/// every Dropbox request behind it just as a wedged request would (#1279). class DropboxAuthManager { DropboxAuthManager({ this.appKey = dropboxAppKey, @@ -22,7 +28,7 @@ class DropboxAuthManager { DateTime Function()? now, String Function()? verifierGenerator, }) : _store = store ?? DropboxAuthStore(), - _http = httpClient ?? http.Client(), + _http = httpClient ?? TimeoutHttpClient.overSockets(), _now = now ?? DateTime.now, _generateVerifier = verifierGenerator ?? generateCodeVerifier; @@ -51,6 +57,12 @@ class DropboxAuthManager { final DateTime Function() _now; final String Function() _generateVerifier; + /// The transport actually in use, so a test can assert that a manager + /// nobody handed one to did not fall back to a deadline-free + /// `http.Client()`. + @visibleForTesting + http.Client get transport => _http; + String? _pendingVerifier; String? _accessToken; DateTime? _accessTokenExpiry; diff --git a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart index 0053fe1474..46f4491301 100644 --- a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart @@ -10,6 +10,7 @@ import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.da import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; import 'package:submersion/core/services/logger_service.dart'; /// Runs the user-consent step of the loopback flow and returns credentials. @@ -57,11 +58,20 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { _obtainConsent = obtainConsent ?? gauth.obtainAccessCredentialsViaUserConsent, _buildClient = buildClient ?? gauth.autoRefreshingClient, - _baseClientFactory = baseClientFactory ?? http.Client.new, + _baseClientFactory = baseClientFactory ?? _timedClient, _launchBrowser = launchBrowser ?? launchUrlString; static final _log = LoggerService.forClass(DesktopOAuthAuthenticator); + /// Base transport for the refreshing client, the consent-flow token + /// exchange, and revocation. + /// + /// A bare `http.Client()` has no connect, response or read deadline, so a + /// wedged socket parked every Drive call made through the refreshing client + /// on top of it -- on sync and, via `mediaHttpClient()`, on the media + /// transfer queue (#1279). + static http.Client _timedClient() => TimeoutHttpClient.overSockets(); + /// openid + email are included so the id_token carries the account email /// for the settings tile subtitle; drive.appdata is the only Drive scope. static const List scopes = [ diff --git a/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart index e2b61e85c4..81cc1ecb74 100644 --- a/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart @@ -3,12 +3,12 @@ import 'dart:io'; import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:googleapis/drive/v3.dart' as drive; -import 'package:googleapis_auth/googleapis_auth.dart' as gapis_auth; import 'package:http/http.dart' as http; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; import 'package:submersion/core/services/logger_service.dart'; /// google_sign_in-backed authenticator for iOS, macOS, and Android. @@ -29,7 +29,12 @@ class GoogleSignInAuthenticator implements GoogleDriveAuthenticator { // hints. final GoogleSignIn _googleSignIn = GoogleSignIn.instance; bool _initialized = false; - gapis_auth.AuthClient? _authClient; + + /// The authorized client, wrapped in [TimeoutHttpClient]. Held as a plain + /// [http.Client] because that wrapper is what everything downstream uses: + /// the provider builds its DriveApi from it and the media store sends raw + /// REST over it, and neither needs the AuthClient surface. + http.Client? _authClient; GoogleSignInAccount? _currentUser; @override @@ -110,7 +115,10 @@ class GoogleSignInAuthenticator implements GoogleDriveAuthenticator { GoogleSignInClientAuthorization authorization, ) { _authClient?.close(); - _authClient = authorization.authClient(scopes: _scopes); + // google_sign_in builds the authorized client over a transport this app + // never gets to configure, so the deadlines go on the outside. Closing + // the wrapper closes the client underneath it (#1279). + _authClient = TimeoutHttpClient(authorization.authClient(scopes: _scopes)); _currentUser = account; } diff --git a/lib/core/services/cloud_storage/http_timeouts.dart b/lib/core/services/cloud_storage/http_timeouts.dart new file mode 100644 index 0000000000..09f7320412 --- /dev/null +++ b/lib/core/services/cloud_storage/http_timeouts.dart @@ -0,0 +1,141 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +/// An [http.Client] decorator that gives every request a deadline (#1279). +/// +/// A bare `http.Client()` has none. On Dart IO it leaves +/// `HttpClient.connectionTimeout` null, so a TCP connect to an unreachable +/// endpoint waits on the OS default -- minutes on some Windows configurations +/// -- and it adds no response or read deadline at all, so a socket that +/// connects and then wedges parks its request forever. The media transfer +/// queue is sequential and single-flight, so one such request used to freeze +/// the whole drain (#1270). +/// +/// Wrap a transport in this and both halves are covered: [overSockets] bounds +/// a connection that never establishes, and [send] bounds one that establishes +/// and then goes quiet. +/// +/// `S3ApiClient` applies the same four deadlines inline rather than through +/// this wrapper. It interleaves them with its own SigV4 retry loop, which +/// classifies `TimeoutException` as a retryable transport fault and replays +/// the request; unpicking that is a change to a well-covered path with no +/// behaviour to gain. The constants here are deliberately its constants. +class TimeoutHttpClient extends http.BaseClient { + TimeoutHttpClient( + this._inner, { + this.responseTimeout = defaultResponseTimeout, + this.uploadTimeout = defaultUploadTimeout, + this.idleTimeout = defaultIdleTimeout, + this.connectTimeout, + }); + + /// A wrapper over a fresh IO transport whose TCP connect is bounded too. + /// + /// This is the constructor callers want unless they are decorating a client + /// somebody else built (an OAuth refreshing client, say), which cannot have + /// its socket layer reconfigured after the fact. + factory TimeoutHttpClient.overSockets({ + Duration connectTimeout = defaultConnectTimeout, + Duration responseTimeout = defaultResponseTimeout, + Duration uploadTimeout = defaultUploadTimeout, + Duration idleTimeout = defaultIdleTimeout, + }) => TimeoutHttpClient( + IOClient(HttpClient()..connectionTimeout = connectTimeout), + responseTimeout: responseTimeout, + uploadTimeout: uploadTimeout, + idleTimeout: idleTimeout, + connectTimeout: connectTimeout, + ); + + /// How long to wait for the TCP connection itself. + static const Duration defaultConnectTimeout = Duration(seconds: 15); + + /// How long to wait for a response's status and headers on a request that + /// carries no meaningful body. Covers TLS setup and the server's own think + /// time. + static const Duration defaultResponseTimeout = Duration(seconds: 30); + + /// The same deadline for a request that CARRIES a body. + /// + /// Separate and far more generous, because `Client.send` does not complete + /// until the body has been written: for a PUT the "wait for a response" + /// window contains the whole upload. A media chunk is 8 MiB, which at + /// 110 kbps takes ten minutes, and killing a transfer that was making + /// progress is exactly the failure mode that left large first syncs failing + /// nine times in ten (#942). Ten minutes still bounds a socket that has + /// genuinely wedged, which is all this needs to do. + static const Duration defaultUploadTimeout = Duration(minutes: 10); + + /// How long a response body may go without delivering a single byte. + /// + /// Deliberately an IDLE gap rather than a total budget: a legitimate 8 MiB + /// download over a weak mobile link takes minutes and must not be killed, + /// while a connection that has stopped delivering is dead regardless of how + /// little it had left to send. + static const Duration defaultIdleTimeout = Duration(seconds: 30); + + /// Above this many declared body bytes a request is treated as an upload. + /// + /// A Dropbox RPC POSTs a few dozen bytes of JSON; writing that is not a + /// transfer, and a wedged one should fail on the seconds-scale response + /// deadline rather than sit for the ten minutes a real multi-megabyte + /// upload is allowed. 64 KiB is comfortably above every control-plane body + /// the app sends and far below the smallest chunk it uploads. + static const int uploadBodyThresholdBytes = 64 * 1024; + + /// Methods that do not carry a body, used to classify a request whose + /// length is unknown until it is written. + static const Set _bodilessMethods = {'GET', 'HEAD', 'DELETE'}; + + final http.Client _inner; + + /// Deadline for a response's status and headers, on a request with no + /// meaningful body. + final Duration responseTimeout; + + /// The same deadline for a request whose body has to be written first. + final Duration uploadTimeout; + + /// Longest gap the response body may go without delivering a byte. + final Duration idleTimeout; + + /// Connect deadline configured on the transport this wrapper owns, or null + /// when the inner client came from the caller and its socket layer is not + /// ours to configure. + final Duration? connectTimeout; + + @override + Future send(http.BaseRequest request) async { + final response = await _inner + .send(request) + .timeout(_carriesUpload(request) ? uploadTimeout : responseTimeout); + return http.StreamedResponse( + response.stream.timeout(idleTimeout), + response.statusCode, + contentLength: response.contentLength, + request: response.request, + headers: response.headers, + isRedirect: response.isRedirect, + persistentConnection: response.persistentConnection, + reasonPhrase: response.reasonPhrase, + ); + } + + @override + void close() => _inner.close(); + + /// Whether [request] should get the generous body-carrying deadline. + /// + /// A declared length settles it. When there is none the method decides, + /// because googleapis' own `RequestImpl` never sets `contentLength` -- + /// upload or not -- so a length-only rule would put every Drive upload on + /// the short response deadline and start killing healthy transfers. + static bool _carriesUpload(http.BaseRequest request) { + final length = request.contentLength; + if (length != null) return length > uploadBodyThresholdBytes; + return !_bodilessMethods.contains(request.method.toUpperCase()); + } +} diff --git a/test/core/services/cloud_storage/dropbox/dropbox_api_client_timeout_test.dart b/test/core/services/cloud_storage/dropbox/dropbox_api_client_timeout_test.dart new file mode 100644 index 0000000000..4d9b58d32f --- /dev/null +++ b/test/core/services/cloud_storage/dropbox/dropbox_api_client_timeout_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_api_client.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; + +/// Request deadlines on the Dropbox transport (#1279). +/// +/// The client used to default to a bare `http.Client()`, so a wedged socket +/// parked its request forever -- on the sync path and, through +/// `DropboxMediaObjectStore`, on the media transfer queue, where a single +/// stuck entry froze the sequential drain (#1270). + +/// A client that accepts the request and then never answers, or answers its +/// headers and then never sends a body byte. +class _StallingClient extends http.BaseClient { + _StallingClient({required this.stallBeforeHeaders}); + + final bool stallBeforeHeaders; + + @override + Future send(http.BaseRequest request) async { + if (stallBeforeHeaders) { + return Completer().future; + } + return http.StreamedResponse( + StreamController>().stream, + 200, + contentLength: 1024, + ); + } +} + +void main() { + DropboxApiClient clientOver(http.Client transport) => DropboxApiClient( + getAccessToken: () async => 'token', + onAccessTokenRejected: () {}, + httpClient: transport, + ); + + DropboxApiClient stalling({required bool beforeHeaders}) => clientOver( + TimeoutHttpClient( + _StallingClient(stallBeforeHeaders: beforeHeaders), + responseTimeout: const Duration(milliseconds: 60), + uploadTimeout: const Duration(milliseconds: 60), + idleTimeout: const Duration(milliseconds: 60), + ), + ); + + test( + 'a response that never arrives surfaces as a reachability error', + () async { + await expectLater( + stalling(beforeHeaders: true).getMetadata('/db.sqlite'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Could not reach Dropbox', + ), + ), + ); + }, + ); + + test('a body that stops mid-stream surfaces the same way', () async { + await expectLater( + stalling(beforeHeaders: false).download('/db.sqlite'), + throwsA(isA()), + ); + }); + + test('an upload that stalls gives up instead of hanging', () async { + await expectLater( + stalling(beforeHeaders: true).upload('/db.sqlite', Uint8List(8)), + throwsA(isA()), + ); + }); + + test('the default transport carries deadlines', () { + // The whole point of #1279: a client nobody handed a transport to must + // not fall back to a bare http.Client(). + final client = DropboxApiClient( + getAccessToken: () async => 'token', + onAccessTokenRejected: () {}, + ); + addTearDown(client.close); + + final transport = client.transport; + expect(transport, isA()); + expect( + (transport as TimeoutHttpClient).connectTimeout, + TimeoutHttpClient.defaultConnectTimeout, + ); + }); + + test('an injected transport is left exactly as the caller built it', () { + final injected = _StallingClient(stallBeforeHeaders: true); + final client = clientOver(injected); + + expect(client.transport, same(injected)); + }); +} diff --git a/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart b/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart index 58a3aedfd4..5d5558bb02 100644 --- a/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart +++ b/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart @@ -7,6 +7,7 @@ import 'package:http/testing.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_store.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; import 'package:submersion/core/services/oauth/oauth_pkce.dart'; import '../../../../support/fake_keychain_storage.dart'; @@ -268,6 +269,22 @@ void main() { }); }); + group('transport', () { + test('defaults to a client with request deadlines', () { + // Token refresh is awaited inside DropboxApiClient's send loop, so a + // wedged refresh on a deadline-free client stalls every Dropbox + // request behind it (#1279). + final m = DropboxAuthManager(store: store); + addTearDown(() => m.transport.close()); + + expect(m.transport, isA()); + expect( + (m.transport as TimeoutHttpClient).connectTimeout, + TimeoutHttpClient.defaultConnectTimeout, + ); + }); + }); + group('disconnect', () { test('revokes best-effort and clears the store', () async { await store.save(DropboxAuthData(refreshToken: 'rt-stored')); diff --git a/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart index 19be2162b6..55330c3848 100644 --- a/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart +++ b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart @@ -9,6 +9,7 @@ import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.da import 'package:submersion/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; class _MemoryTokenStore implements GoogleDriveTokenStore { gauth.AccessCredentials? stored; @@ -140,6 +141,34 @@ void main() { }); }); + test('the default base transport carries request deadlines', () async { + // The refreshing client sends every Drive call, and its own token + // refreshes, over this base. A bare http.Client() has no connect, + // response or read deadline, so a wedged socket parked the request + // forever -- on sync and, via mediaHttpClient(), on the media transfer + // queue (#1279). + store.stored = creds(refreshToken: 'rt-1'); + late http.Client capturedBase; + final auth = DesktopOAuthAuthenticator( + tokenStore: store, + obtainConsent: (clientId, scopes, client, prompt) async => creds(), + buildClient: (clientId, credentials, base) { + capturedBase = base; + return _FakeRefreshingClient(credentials); + }, + launchBrowser: (url) async {}, + ); + + expect(await auth.attemptSilentAuth(), isTrue); + + addTearDown(capturedBase.close); + expect(capturedBase, isA()); + expect( + (capturedBase as TimeoutHttpClient).connectTimeout, + TimeoutHttpClient.defaultConnectTimeout, + ); + }); + test('attemptSilentAuth returns false with no stored credentials', () async { final auth = authenticator(); expect(await auth.attemptSilentAuth(), isFalse); diff --git a/test/core/services/cloud_storage/google_drive/google_sign_in_authenticator_test.dart b/test/core/services/cloud_storage/google_drive/google_sign_in_authenticator_test.dart index f3fc4aa54d..ab4f920100 100644 --- a/test/core/services/cloud_storage/google_drive/google_sign_in_authenticator_test.dart +++ b/test/core/services/cloud_storage/google_drive/google_sign_in_authenticator_test.dart @@ -8,6 +8,7 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; /// Drives [GoogleSignInAuthenticator] through a fake [GoogleSignInPlatform] /// rather than the real plugin, so the mobile/macOS auth path is exercisable @@ -106,6 +107,19 @@ void main() { expect(await auth.userEmail, 'diver@example.com'); }); + test('installs a client with request deadlines', () async { + // google_sign_in builds the authorized client itself, over a transport + // this app never sees, so the deadlines have to go on the outside. A + // bare client parked a wedged Drive request forever -- on sync and, via + // mediaHttpClient(), on the media transfer queue (#1279). + platform.lightweightResult = _FakePlatform.resultsFor( + 'diver@example.com', + ); + + expect(await auth.attemptSilentAuth(), isTrue); + expect(auth.authClient, isA()); + }); + test('returns false when there is no cached session', () async { platform.lightweightResult = null; diff --git a/test/core/services/cloud_storage/http_timeouts_test.dart b/test/core/services/cloud_storage/http_timeouts_test.dart new file mode 100644 index 0000000000..5849e95c60 --- /dev/null +++ b/test/core/services/cloud_storage/http_timeouts_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/cloud_storage/http_timeouts.dart'; + +/// Request deadlines for the non-S3 cloud transports (#1279). +/// +/// `S3ApiClient` was the only transport in the app with deadlines. Dropbox and +/// Google Drive both fell back to a bare `http.Client()`, which on Dart IO +/// leaves `HttpClient.connectionTimeout` null and adds no response or read +/// deadline at all, so a socket that connected and then wedged parked its +/// request forever. In the media pipeline that showed up as a queue entry +/// stuck in `transferring` (#1270). +/// +/// [TimeoutHttpClient] is the shared piece: a decorator any client can be +/// wrapped in, so the deadline policy lives in one place rather than being +/// re-derived per transport. + +/// A client that accepts the request and then never answers, or answers its +/// headers and then never sends a body byte. +class _StallingClient extends http.BaseClient { + _StallingClient({required this.stallBeforeHeaders}); + + final bool stallBeforeHeaders; + final List requests = []; + + @override + Future send(http.BaseRequest request) async { + requests.add(request); + if (stallBeforeHeaders) { + // Never completes: a half-open socket after the request was written. + return Completer().future; + } + // Headers arrive, then the body stream goes quiet forever. + return http.StreamedResponse( + StreamController>().stream, + 200, + contentLength: 1024, + ); + } +} + +class _EchoClient extends http.BaseClient { + bool closed = false; + + @override + Future send(http.BaseRequest request) async => + http.StreamedResponse( + Stream>.value(const [7]), + 200, + contentLength: 1, + headers: const {'x-echo': 'yes'}, + reasonPhrase: 'OK', + ); + + @override + void close() => closed = true; +} + +/// A request whose length is unknown until it is written, which is what +/// googleapis' own `RequestImpl` produces for every call it makes. +class _UnsizedRequest extends http.BaseRequest { + _UnsizedRequest(super.method, super.url); + + @override + http.ByteStream finalize() { + super.finalize(); + return const http.ByteStream(Stream>.empty()); + } +} + +void main() { + TimeoutHttpClient wrap( + http.Client inner, { + Duration response = const Duration(milliseconds: 60), + Duration upload = const Duration(milliseconds: 400), + Duration idle = const Duration(milliseconds: 60), + }) => TimeoutHttpClient( + inner, + responseTimeout: response, + uploadTimeout: upload, + idleTimeout: idle, + ); + + http.Request get(String url) => http.Request('GET', Uri.parse(url)); + + test('a response that never arrives gives up instead of hanging', () async { + final client = wrap(_StallingClient(stallBeforeHeaders: true)); + + await expectLater( + client.send(get('https://example.test/a')), + throwsA(isA()), + ); + }); + + test('a body that stops mid-stream gives up instead of hanging', () async { + final client = wrap(_StallingClient(stallBeforeHeaders: false)); + + final streamed = await client.send(get('https://example.test/a')); + + await expectLater( + streamed.stream.toBytes(), + throwsA(isA()), + ); + }); + + test('an upload gets its own, far longer deadline', () async { + // `Client.send` does not complete until the body has been written, so for + // a PUT the "wait for a response" window contains the whole upload. An + // 8 MiB chunk on a weak link legitimately takes minutes, and killing one + // that was making progress is the failure mode the S3 client's own + // deadlines were tuned to avoid (#942). + final client = wrap( + _StallingClient(stallBeforeHeaders: true), + response: const Duration(milliseconds: 1), + upload: const Duration(milliseconds: 300), + ); + final request = http.Request('PUT', Uri.parse('https://example.test/a')) + ..bodyBytes = Uint8List(TimeoutHttpClient.uploadBodyThresholdBytes + 1); + + final started = DateTime.now(); + await expectLater(client.send(request), throwsA(isA())); + + expect( + DateTime.now().difference(started), + greaterThan(const Duration(milliseconds: 150)), + reason: 'the upload must not be cut off by the read deadline', + ); + }); + + test('a small POST body stays on the short response deadline', () async { + // A Dropbox RPC carries a few dozen bytes of JSON. Writing that is not an + // upload, so a wedged one must fail in seconds, not in the ten minutes a + // real multi-megabyte transfer is allowed. + final client = wrap( + _StallingClient(stallBeforeHeaders: true), + response: const Duration(milliseconds: 60), + upload: const Duration(seconds: 30), + ); + final request = http.Request('POST', Uri.parse('https://example.test/a')) + ..body = '{"path":"/x"}'; + + await expectLater(client.send(request), throwsA(isA())); + }); + + test('an unsized non-GET request is treated as an upload', () async { + // googleapis' RequestImpl never sets contentLength, upload or not, so a + // length-only rule would put a multi-megabyte Drive upload on the 30s + // response deadline and start killing healthy transfers. + final client = wrap( + _StallingClient(stallBeforeHeaders: true), + response: const Duration(milliseconds: 1), + upload: const Duration(milliseconds: 300), + ); + + final started = DateTime.now(); + await expectLater( + client.send(_UnsizedRequest('POST', Uri.parse('https://example.test/a'))), + throwsA(isA()), + ); + + expect( + DateTime.now().difference(started), + greaterThan(const Duration(milliseconds: 150)), + ); + }); + + test('an unsized GET stays on the short response deadline', () async { + final client = wrap( + _StallingClient(stallBeforeHeaders: true), + response: const Duration(milliseconds: 60), + upload: const Duration(seconds: 30), + ); + + await expectLater( + client.send(_UnsizedRequest('GET', Uri.parse('https://example.test/a'))), + throwsA(isA()), + ); + }); + + test('a prompt response passes through untouched', () async { + final client = wrap(_EchoClient()); + + final streamed = await client.send(get('https://example.test/a')); + + expect(streamed.statusCode, 200); + expect(streamed.contentLength, 1); + expect(streamed.headers['x-echo'], 'yes'); + expect(streamed.reasonPhrase, 'OK'); + expect(await streamed.stream.toBytes(), Uint8List.fromList([7])); + }); + + test('closing the wrapper closes the client underneath it', () { + final inner = _EchoClient(); + + wrap(inner).close(); + + expect(inner.closed, isTrue); + }); + + test('the socket-backed factory bounds the TCP connect itself', () { + // The deadlines above bound a socket that connects and then stalls. A + // bare http.Client() leaves HttpClient.connectionTimeout null, so one + // that never connects waits on the OS default -- minutes on some Windows + // configurations. + final client = TimeoutHttpClient.overSockets( + connectTimeout: const Duration(seconds: 3), + ); + addTearDown(client.close); + + expect(client.connectTimeout, const Duration(seconds: 3)); + }); + + test('the defaults match the deadlines the S3 client settled on', () { + expect( + TimeoutHttpClient.defaultConnectTimeout, + const Duration(seconds: 15), + ); + expect( + TimeoutHttpClient.defaultResponseTimeout, + const Duration(seconds: 30), + ); + expect(TimeoutHttpClient.defaultUploadTimeout, const Duration(minutes: 10)); + expect(TimeoutHttpClient.defaultIdleTimeout, const Duration(seconds: 30)); + }); +} From 067194dd8896aec4bc34be1e40861d7cdbcb4773 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:39:33 -0400 Subject: [PATCH 033/122] fix(dc-import): drop a stale discovery selection before a saved-computer download Review follow-up on #1281. The known-computer step only hid a leftover selection whose address did not match the saved computer inside build(); the provider still held it, and the completion path reads the provider's selection to capture the descriptor the import service records. The step now clears the mismatched selection from the provider before scanning or falling back, for Bluetooth and USB computers alike, and the build-time guard is gone. Also pins the widget test's locale to en, as the sibling tests do, since it asserts on English text. --- .../providers/discovery_providers.dart | 9 +++++ .../widgets/dc_adapter_steps.dart | 20 +++++------ ..._adapter_download_step_reacquire_test.dart | 36 +++++++++++++++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/lib/features/dive_computer/presentation/providers/discovery_providers.dart b/lib/features/dive_computer/presentation/providers/discovery_providers.dart index 3a4f747269..2b9084de26 100644 --- a/lib/features/dive_computer/presentation/providers/discovery_providers.dart +++ b/lib/features/dive_computer/presentation/providers/discovery_providers.dart @@ -310,6 +310,15 @@ class DiscoveryNotifier extends StateNotifier { ); } + /// Drop the selected device without touching the rest of the state. + /// + /// Used when a selection left over from an earlier discovery session must + /// not be reused, e.g. it does not carry the address of the saved computer + /// about to be downloaded from. + void clearSelectedDevice() { + state = state.copyWith(clearDevice: true); + } + /// Set a custom name for the device. void setCustomName(String name) { state = state.copyWith(customDeviceName: name); diff --git a/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart b/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart index 940f25f047..18ec7f231f 100644 --- a/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart +++ b/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart @@ -321,11 +321,20 @@ class _DcAdapterDownloadStepState extends ConsumerState { Future _reacquireKnownDevice(DiveComputer computer) async { if (!mounted) return; final address = computer.bluetoothAddress; + final notifier = ref.read(discoveryNotifierProvider.notifier); final selected = ref.read(discoveryNotifierProvider).selectedDevice; final alreadyAcquired = selected != null && address != null && bluetoothAddressesMatch(selected.address, address); + // A saved computer downloads only from a device carrying its stored + // address. A selection left over from an earlier discovery session is + // dropped from the provider itself, because the completion path reads + // the provider's selection to capture the descriptor the import + // service records; hiding it locally here would not be enough. + if (selected != null && !alreadyAcquired) { + notifier.clearSelectedDevice(); + } final isBluetooth = _connectionTypeFromString(computer.connectionType) == DeviceConnectionType.ble; @@ -336,7 +345,6 @@ class _DcAdapterDownloadStepState extends ConsumerState { } setState(() => _searchingForKnownDevice = true); - final notifier = ref.read(discoveryNotifierProvider.notifier); final device = await notifier.scanForAddress( address, timeout: DcAdapterDownloadStep.knownDeviceScanTimeout, @@ -389,16 +397,6 @@ class _DcAdapterDownloadStepState extends ConsumerState { var device = discoveryState.selectedDevice; final computer = widget.knownComputer ?? widget.adapter.computer; - // A saved computer downloads only from a device carrying its stored - // address: a device left selected by an earlier discovery session must - // not be used in its place. - final storedAddress = widget.knownComputer?.bluetoothAddress; - if (device != null && - storedAddress != null && - !bluetoothAddressesMatch(device.address, storedAddress)) { - device = null; - } - // For known-computer downloads, synthesize a DiscoveredDevice from the // computer's stored connection info when discovery state has no device. // The device descriptor lookup provides the dcModel integer that diff --git a/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart index 114141f7ac..970cf52607 100644 --- a/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart +++ b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart @@ -139,6 +139,7 @@ class _Harness { firstSyncCutoffDefaultProvider.overrideWith((ref) async => null), ], child: MaterialApp( + locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( @@ -162,6 +163,12 @@ Future _settle(WidgetTester tester) async { } } +/// The device currently selected in the discovery provider the step reads. +DiscoveredDevice? _selectedDevice(WidgetTester tester) => + ProviderScope.containerOf( + tester.element(find.byType(DcAdapterDownloadStep)), + ).read(discoveryNotifierProvider).selectedDevice; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -252,11 +259,40 @@ void main() { expect(h.hostApi.calls, ['startDiscovery']); expect(find.byType(DownloadStepWidget), findsNothing); + // The stale selection is dropped from the provider itself, not just + // ignored here: the completion path reads the provider's selection + // to capture the descriptor the import service records. + expect(_selectedDevice(tester), isNull); h.service.onDeviceDiscovered(_advert(_savedAddress)); await _settle(tester); expect(h.hostApi.downloads.single.address, _savedAddress); + expect(_selectedDevice(tester)?.address, _savedAddress); + }, + ); + + testWidgets( + 'drops a stale Bluetooth selection for a USB computer without scanning', + (tester) async { + final h = _Harness( + seed: DiscoveryState( + selectedDevice: _discovered('11:22:33:44:55:66'), + ), + ); + await tester.pumpWidget( + h.build( + _savedComputer( + connectionType: 'usb', + bluetoothAddress: '/dev/ttyUSB0', + ), + ), + ); + await _settle(tester); + + expect(h.hostApi.calls, ['startDownload']); + expect(h.hostApi.downloads.single.address, '/dev/ttyUSB0'); + expect(_selectedDevice(tester), isNull); }, ); From ab846e3c43696a788e31c8b191e689390829e47d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:49:04 -0400 Subject: [PATCH 034/122] docs: stop claiming a header-only copy is reachable from the UI Review feedback on #1282. copyFilteredLogs said the header is prepended "even when the filters exclude everything", implying a user-visible behaviour. The Copy button in DebugLogViewerPage guards on a non-empty list, so an empty filter result copies nothing at all rather than a bare header, and an existing widget test pins exactly that. The header genuinely is unconditional inside the function, so the unit test stays as its contract. Both comments now say which of the two they describe. --- .../presentation/providers/debug_log_providers.dart | 9 ++++++--- .../presentation/providers/debug_log_providers_test.dart | 5 +++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/features/settings/presentation/providers/debug_log_providers.dart b/lib/features/settings/presentation/providers/debug_log_providers.dart index 7ae7d595f0..c41f786675 100644 --- a/lib/features/settings/presentation/providers/debug_log_providers.dart +++ b/lib/features/settings/presentation/providers/debug_log_providers.dart @@ -182,10 +182,13 @@ Future shareLogFile( ); } -/// Copy the filtered log entries to clipboard. +/// Copy the filtered log entries to clipboard, behind the export header. /// -/// The header is prepended even when the filters exclude everything: an empty -/// excerpt that still names the build is more useful than a bare empty string. +/// The header is unconditional, so even a one-line excerpt names the build it +/// came from. That is deliberately not special-cased for an empty list, but it +/// is also not reachable that way today: the Copy button in +/// [DebugLogViewerPage] guards on a non-empty list, so an empty filter result +/// copies nothing at all rather than a bare header. Future copyFilteredLogs( List entries, { LogEnvironment? environment, diff --git a/test/features/settings/presentation/providers/debug_log_providers_test.dart b/test/features/settings/presentation/providers/debug_log_providers_test.dart index 43d3be848f..b3b33c9453 100644 --- a/test/features/settings/presentation/providers/debug_log_providers_test.dart +++ b/test/features/settings/presentation/providers/debug_log_providers_test.dart @@ -752,8 +752,9 @@ void main() { }); test('copies the header alone when entries list is empty', () async { - // An excerpt filtered down to nothing still names the build, which is - // more useful than the bare empty string this used to produce. + // This is the function's contract, not a UI path: the Copy button + // guards on a non-empty list, so DebugLogViewerPage never reaches it. + // Pinned so the header stays unconditional if that guard is relaxed. await copyFilteredLogs([], environment: _environment); final setDataCall = clipboardCalls.firstWhere( From 0f11ec39e10472ef5bc59dd823a7172456fa59d3 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 25 Aug 2026 23:59:00 -0400 Subject: [PATCH 035/122] docs(sites): design spec for location details from coordinates (#1187) --- ...5-site-location-from-coordinates-design.md | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md diff --git a/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md b/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md new file mode 100644 index 0000000000..777e0b38d6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md @@ -0,0 +1,329 @@ +# Dive Site Location From Coordinates: Design + +**Status:** approved 2026-08-25 +**Issue:** #1187 +**Branches:** `worktree-issue-1187-site-field-wipe` (PR A, bounded fix) and +`worktree-issue-1187-site-geocoding` (PR B, this design) +**Supersedes nothing.** Extends the reverse-geocoding introduced in v1.1 and +the English pin from issue #214 (PR #784). + +## Problem + +Issue #1187 (Android 1.7.5.6566, German UI) bundles three complaints: + +1. Picking coordinates fills only Country and Region. Town and body of water + stay empty even though the picker preview already shows the town + ("Weggis, Luzern, Switzerland"). Island is also empty. +2. The filled names are English ("Switzerland" in a German UI). +3. The reporter's stated real reason for the report: site data they entered + by hand, including how difficult each site is, disappears and is then + missing on both their Windows and Android devices. They assumed sync. + +## Findings + +Every claim below was verified by reading the cited lines in this worktree +and, for the OpenStreetMap ones, by querying Nominatim with the reporter's own +coordinates (47.027631, 8.400640). + +**F1. The data loss is a local whole-row overwrite, not sync.** Sync +serialises and applies every `dive_sites` column with full-row +`toJson`/`fromJson` (`sync_data_serializer.dart:4556`, `:2419`), merged +per row by HLC. The wipe happens earlier: +`dive_repository_impl.dart:3000` and `:3359` build `dive.site` with 9 of the +entity's 24 fields (no `difficulty`, `waterType`, `minDepth`, `city`, +`island`, `bodyOfWater`, `hazards`, `accessNotes`, `mooringNumber`, +`parkingInfo`, `entryMethod`, `exitMethod`, `isShared`). +`altitude_resolver.dart:56` does `site.copyWith(altitude: meters)` on that +partial entity and `dive_altitude_enricher.dart:41` hands it to +`SiteRepository.updateSite`, whose `_writeSiteUpdate` +(`site_repository_impl.dart:173-196`) writes every column unconditionally. +`dive_edit_page.dart:4104` (altitude autofill) and `:2255` (photo GPS +write-back) do the same. The row is marked pending with a fresh HLC, so the +other device accepts the wipe as a newer edit. That is "missing from both". +The trigger is ordinary: import or edit any dive at a site that has no +stored altitude. "How difficult each dive site is" is the `difficulty` +column, one of the wiped ones. + +**F2. Town is already returned and then discarded.** +`LocationService.reverseGeocode` (`location_service.dart:224`) returns +`(country, region, locality)`; the web path maps +`city ?? town ?? village` into `locality` (`:268-312`). +`site_edit_page.dart` fills only `country` and `region` in all three flows +(`_geocodeSeed` `:210`, `_useMyLocation` `:1379`, `_pickFromMap` `:1429`). +`LocationPickerMap` returns `locality` in `PickedLocation` and the page +ignores it. + +**F3. Body of water is available for lakes and bays, not for seas.** +Nominatim's default (address) layer never mentions water. With +`layer=natural`, the reporter's point returns class `water`, type `lake`, +name "Lake Lucerne" (English) or "Vierwaldstättersee" (German). A point in +the middle of the same lake returned class `natural`, type +`mountain_range`, "Urner Alps", so the result must be filtered by class. +Open-sea points (Cozumel, Ras Mohammed) return "Unable to geocode" on the +natural layer because OpenStreetMap does not map oceans as polygons. + +**F4. Island has no reliable source.** Bonaire came back as +`municipality`, Cozumel as `county`, Sa Dragonera (Mallorca) as nothing. +There is no `island` address key to read. Guessing from `municipality` or +`county` would be wrong more often than right. + +**F5. The English pin is deliberate and the reason no longer fully +applies.** `location_service.dart:54-63` pins `Locale('en')` and +`accept-language=en` because issue #214 saw the platform geocoder answer in +the device locale, storing "Spanien" on one device and "España" on another, +which fragmented statistics grouping. Since then the app language became a +synced per-diver setting (`diver_settings.locale`, +`database.dart:1663`). A synced, explicit language code gives every device +of one diver the same answer, which is what #214 actually needed. + +**F6. No re-lookup exists.** Geocoding fires only when a new site is seeded +from a dive's GPS, on "Use my location", and on "Pick from map", and each +fills only empty country/region. Sites created before PR #784 or by import +have no way to be enriched. The bug-campaign log +(`docs/superpowers/plans/2026-07-31-bug-campaign-log.md:61`) already notes +the missing backfill. + +**F7. `SiteRepositoryImpl._mapRowToSite` (`site_repository_impl.dart:820`) +is a pure row-to-entity mapper** (no photo loading, no I/O), so it can be +shared with the dive repository without changing behaviour. + +## Scope + +Confirmed with the user on 2026-08-25: + +- **PR A (bounded, lands first):** stop the whole-row overwrite (F1). +- **PR B (this design):** fill town and body of water from coordinates, a + synced "place name language" setting, a per-site "Look up from + coordinates" action, and a bulk "Fill in missing location details" action + that only ever writes empty fields. +- Island stays a manual field (F4). The issue reply will say why. +- Body of water is filled for lakes, reservoirs, rivers, bays and straits. + Seas are not filled (F3). The issue reply will say why. +- The bulk action never overwrites existing values. A per-site explicit + lookup can, after confirmation. +- The place name language defaults to English so no existing user's data + changes shape. There is no "follow app language" mode, because the app + language can be `system`, which resolves per device and would reopen #214. + +## PR A: stop wiping site fields + +Not part of this spec's implementation plan; recorded here because it is the +reporter's real complaint and the two PRs share the issue. + +1. Extract `_mapRowToSite` into `mapDiveSiteRow(DiveSite row)` in + `lib/features/dive_sites/data/mappers/dive_site_row_mapper.dart` and use + it from both `SiteRepositoryImpl` and the two sites in + `dive_repository_impl.dart`. `dive.site` then carries every column. +2. The three write-backs stop routing a whole entity through `updateSite`. + `AltitudeResolution.siteWriteBack` becomes `siteAltitudeWriteBack: + ({String siteId, double altitudeMeters})?` and callers apply it with the + existing column-patch method + `applyImportedMetadata(siteId, DiveSitesCompanion(altitude: Value(m)))`, + which marks the row pending and stamps HLC exactly like `updateSite`. + `_updateSiteWithPhotoGps` patches latitude, longitude and altitude the + same way. A targeted patch cannot clobber columns it never read. +3. Tests: a `DiveAltitudeEnricher` test seeding a site with difficulty, + water type, city, body of water, hazards and `isShared = true` and no + altitude, importing a dive there, asserting every field survives and + altitude is set; a `DiveRepository.getDive` test asserting `dive.site` + carries `difficulty` and the other previously missing fields. +4. Data already lost is not recoverable by code. The issue reply says so. + +## PR B design + +### 1. LocationService contract + +`reverseGeocode` returns a `PlaceLookup` value and takes a required +language code: + +```dart +class PlaceLookup { + const PlaceLookup({this.country, this.region, this.locality, this.bodyOfWater}); + final String? country; + final String? region; + final String? locality; + final String? bodyOfWater; + bool get isEmpty; +} + +Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, +}); +``` + +- Address lookup keeps its shape: on mobile the `geocoding` placemark with + `Locale(languageCode)`, falling back to Nominatim + `/reverse?format=json&zoom=10&accept-language=` with the existing + key fallbacks (`state ?? province ?? region`, `city ?? town ?? village`). + The `Accept-Language` header carries the same code. +- Body of water is a second, web-only request: + `/reverse?format=json&zoom=14&layer=natural&accept-language=`. + The hit is accepted only when `class == 'water'` (any type: lake, + reservoir, river, ...) or `class == 'natural'` with `type` in + `{bay, strait}`. Everything else, including "Unable to geocode", yields + `bodyOfWater: null`. The name comes from the response's `name` field. A + failure in this request never discards the address result; it is logged + and the lookup returns without a body of water. +- A single `_NominatimThrottle` inside the service delays every Nominatim + request so that consecutive requests are at least one second apart. It + uses `clock.now()` so fakeAsync tests can drive it. This one mechanism + covers the two-request interactive lookup and the bulk pass. +- `buildReverseGeocodeUri` gains `languageCode`; a new + `buildNaturalFeatureUri` builds the natural-layer URI. Both stay public + so tests can pin them. +- `forwardGeocode` (dive centres only) is unchanged. +- The parameter is required so no caller can silently keep the old pin. + Callers: `site_edit_page`, `location_picker_map` (whose `PickedLocation` + gains `bodyOfWater`), `region_download_dialog` (reads the provider), + `uddf_entity_importer` (receives the code through its constructor from + the provider that builds it), and `getCurrentLocation` (which takes the + same parameter and forwards it). + +### 2. Place name language setting + +- Column: `diver_settings.place_name_language TEXT NOT NULL DEFAULT 'en'`. +- Migration v162: `_assertPlaceNameLanguageColumn()` guarded by + `PRAGMA table_info('diver_settings')`, no-op when the table is absent, + modeled on `_assertGasModelColumn`. `currentSchemaVersion` becomes 162 + and 162 is appended to `migrationVersions`. Ladder step follows the v161 + block at `database.dart:8534`. Test + `test/core/database/migration_v162_place_name_language_test.dart` with + the four standard cases (upgrade adds the column with default `'en'`, + fresh DB has it, helper no-ops without the table, ladder contains 162). +- `AppSettings.placeNameLanguage` (String, default `'en'`), `copyWith`, + `SettingsNotifier.setPlaceNameLanguage`, and a + `placeNameLanguageProvider` selector, all following `coordinateFormat`. +- `DiverSettingsRepository`: insert companion, update companion, and row + mapping. The mapping falls back to `'en'` when the stored code is not one + of the app's supported language codes, so a value from a newer peer + cannot put an unknown code into `accept-language`. +- Sync: `_applyDiverSettingDefaults` gets `'placeNameLanguage': 'en'` with + a `// v162:` comment so payloads from older peers hydrate. Add a case to + `test/core/services/sync/sync_diver_settings_fallback_test.dart`. + Export and import are full-row; nothing else changes. +- UI: one `_buildUnitTile` row in `settings_page.dart` beside Coordinate + format. Title "Place name language", value = the language's native name, + subtitle "Used when country, region, town and body of water are looked up + from coordinates. Existing sites are not changed." The picker lives in + `lib/features/settings/presentation/widgets/place_name_language_picker.dart` + following `coordinate_format_picker.dart` (a `show...Picker` function, a + testable list widget, a `placeNameLanguageLabel` function). Options are + `LanguageSettingsPage.supportedLocales` minus `system`, so there is no + second hand-maintained language list. + +### 3. Site form + +- `LocationSection` gains a third action, "Look up from coordinates", + enabled only while both latitude and longitude parse as valid numbers. + The helper text becomes "Choose a location method or look up the + coordinates to auto-fill country, region, town and body of water". +- `site_edit_page` replaces the duplicated fill-empty code in + `_geocodeSeed`, `_useMyLocation` and `_pickFromMap` with one + `_applyPlaceLookup(PlaceLookup lookup, {required bool overwrite})` that + writes `country`, `region`, `city` (from `locality`) and `bodyOfWater`. + With `overwrite: false` only empty controllers change. The seed path + keeps its `_isApplyingInitialValues` wrapper so it does not dirty the + form; the other paths set `_hasChanges` only when a controller changed. +- `_lookupFromCoordinates()`: parse the controllers, show the existing + "getting location" busy state, call the service with the diver's place + name language, then `_applyPlaceLookup(overwrite: false)`. Outcomes: + - at least one field filled: done, form dirty; + - nothing empty and at least one found value differs from the current + one: a dialog lists the found values (only the differing fields) with + Replace and Keep; Replace calls `_applyPlaceLookup(overwrite: true)`; + - lookup returned nothing: snackbar "No location details found for these + coordinates"; + - exception: error snackbar with the existing wording style. +- `LocationPickerMap` passes the language to its preview and confirm + lookups and returns `bodyOfWater` in `PickedLocation`. +- The save path is unchanged. It never geocodes (the v1.5.6 guarantee that + a manually cleared Region stays cleared stands, and the existing tests + for Grand Turk and Bonaire keep passing). + +### 4. Bulk backfill + +- `mergeMissingLocationDetails(current, found)` in + `lib/features/dive_sites/domain/services/site_location_merge.dart`: a + pure function over four nullable strings that returns the values to write + (only where `current` is null or blank and `found` is non-blank) or null + when there is nothing to write. Both the form's fill-empty path and the + bulk service use it, so the "only empty" rule has one home. +- `SiteRepository.fillMissingLocationDetails(String siteId, PlaceLookup + found)`: reads the row, calls the merge function, writes only the + returned columns through a `DiveSitesCompanion` in one transaction, + marks the row pending, notifies the sync event bus, and returns whether + anything changed. The site edit page does not use it (it works on + controllers); the bulk service does. +- `SiteLocationBackfillService` in + `lib/features/dive_sites/domain/services/site_location_backfill_service.dart` + depends on `SiteRepository`, `LocationService` and the language code. + `run({required diverId, required onProgress, required isCancelled})`: + 1. Select the diver's sites that have coordinates and at least one empty + target field (country, region, city, bodyOfWater). Report `total`. + 2. For each site, in order: if `isCancelled()` stop; look up; call + `fillMissingLocationDetails`; report progress. + 3. Per-site failures are logged, counted as failed, and the run + continues. A `SocketException` on the first request aborts the run + with an "offline" outcome so the user is not shown 104 failures. + 4. Return `BackfillSummary(updated, unchanged, failed, cancelled)`. +- `siteLocationBackfillProvider`: a notifier holding `idle | running(done, + total) | finished(summary)`. Starting while running is a no-op. The + dialog reads it, so rebuilds and navigation do not lose the run. +- UI on the sites list overflow menu: "Fill in missing location details…". + Confirmation dialog: "Looks up N sites on OpenStreetMap and fills only + empty country, region, town and body of water fields. Takes about M + minutes." (M from N at two seconds per site.) Then a progress dialog + with a linear indicator, "12 of 104", and Cancel. Then a summary + snackbar: "Updated X sites, Y unchanged, Z failed". + +### 5. Localisation and documentation + +- Every new string is added to all 11 ARB files + (`ar, de, en, es, fr, he, hu, it, nl, pt, zh`). +- No release note is written in the PR; release notes are assembled at + release time from the merged PRs. +- After both PRs merge, reply on #1187 with: the cause of the data loss + and that lost values cannot be recovered by the app; town and body of + water now fill; island has no OpenStreetMap source; seas are not mapped + as areas so body of water works for lakes, reservoirs, rivers, bays and + straits; the new language setting and why its default is English; the + bulk action for their 100+ sites. + +## Testing + +- `location_service_test`: language code in reverse URI, header and native + locale; natural-layer URI; filter accepts lake and bay, rejects + mountain range and saddle and "Unable to geocode"; natural-layer HTTP + failure keeps the address result; throttle spaces two requests one + second apart under fakeAsync. +- Migration v162 ladder test; `DiverSettingsRepository` round-trip and + unknown-code fallback; sync older-peer defaults; picker widget test; + settings row shows the native language name. +- `site_edit_page` tests: explicit lookup fills only empty fields; Replace + dialog path replaces, Keep leaves fields alone; "nothing found" snackbar; + pick-from-map now fills town and body of water (regression for the + discarded `locality`); the existing Grand Turk and Bonaire tests are + unchanged. +- `mergeMissingLocationDetails` unit tests (blank vs null, nothing to + write). +- `SiteLocationBackfillService` tests with a fake repository and fake + location service: selection excludes sites without coordinates and sites + with every field filled; only empty columns are written; cancel between + sites; a failing site is counted and the run continues; offline abort; + summary counts. +- Sites-list dialog widget test: confirmation text, progress updates, + summary snackbar. + +## Out of scope + +- Filling island (F4). +- Seas and oceans as body of water (F3). +- Localising `SiteDifficulty.displayName`, which is hard-coded English + today; unrelated to this issue. +- Field-level sync merging for sites (today the higher HLC wins the whole + row). Worth its own issue; not what the reporter hit. +- Re-geocoding existing values after changing the place name language. The + setting's subtitle says existing sites are not changed; the per-site + Replace dialog covers individual corrections. From 9ddaa18f90a11f7f754417fdc179c2e4ea09a0b2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:01:58 -0400 Subject: [PATCH 036/122] fix(certifications): let the picker's label stand in for the tile it describes The sheet's Semantics wrapper set a label but did not exclude the ListTile subtree, so the label sat alongside the tile's own semantics instead of replacing them, as the comment there already claimed it did. A screen reader announced the row twice: "PADI Bill Ansell, Divemaster" and then "Bill Ansell, PADI - Divemaster". Excluding the subtree drops the tile's tap action along with its text, so the wrapper restates it as a button; without that the row looks tappable but is unreachable by a screen reader. Nothing is lost by excluding: the label already spells out the pieces the tile shows only as icons or chips, appending ", issued ", ", selected" and ", expired". Verified against the rendered semantics tree in both directions, since the Semantics WIDGET looks correct either way and only the tree shows the extra node. The assertion pairs findsOneWidget on the label with findsNothing on the tile's own "title\nsubtitle" node, which fails when excludeSemantics is removed. Adds a test that drives SemanticsAction.tap through the label to pin the restated tap action. The identical wrapper in certification_list_content.dart has the same extra node, but its tile nests an interactive selection checkbox that excludeSemantics would hide, so it needs a different fix and is left alone. --- .../widgets/certification_picker.dart | 9 ++ .../widgets/certification_picker_test.dart | 92 +++++++++++++++---- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/lib/features/certifications/presentation/widgets/certification_picker.dart b/lib/features/certifications/presentation/widgets/certification_picker.dart index 0c3d43cc50..5883bb5ca0 100644 --- a/lib/features/certifications/presentation/widgets/certification_picker.dart +++ b/lib/features/certifications/presentation/widgets/certification_picker.dart @@ -204,6 +204,15 @@ class CertificationPickerSheet extends ConsumerWidget { return Semantics( label: certLabel, + // Without excludeSemantics the label MERGES with the + // tile's own text rather than standing in for it, so a + // screen reader hears the name, agency and level twice. + // Excluding the subtree drops the tile's tap action along + // with its text, so restate it here or the row stops being + // activatable. + excludeSemantics: true, + button: true, + onTap: () => onCertificationSelected(cert), child: ListTile( leading: CircleAvatar( backgroundColor: isSelected diff --git a/test/features/certifications/presentation/widgets/certification_picker_test.dart b/test/features/certifications/presentation/widgets/certification_picker_test.dart index 32dc6a7998..10f6150058 100644 --- a/test/features/certifications/presentation/widgets/certification_picker_test.dart +++ b/test/features/certifications/presentation/widgets/certification_picker_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; import 'package:submersion/core/constants/enums.dart'; @@ -82,23 +83,13 @@ Future _pumpSheet(WidgetTester tester, List certs) async { String _subtitleOf(WidgetTester tester, Finder tile) => ((tester.widget(tile)).subtitle! as Text).data!; -/// The label the sheet's [Semantics] wrapper declares for a tile. +/// The label a tile's own [ListTile] contributes when it is NOT excluded from +/// the semantics tree: its visible title and subtitle, newline-joined. /// -/// Read off the widget rather than queried with `find.bySemanticsLabel`, -/// following the precedent in certification_ecard_test.dart: this wrapper -/// merges with the tile's own text instead of replacing it, so the rendered -/// node is the declared label followed by the visible title and subtitle. -String _tileSemanticsLabel(WidgetTester tester) { - final candidates = find.ancestor( - of: find.byType(ListTile), - matching: find.byType(Semantics), - ); - for (final element in candidates.evaluate()) { - final label = (element.widget as Semantics).properties.label; - if (label != null && label.isNotEmpty) return label; - } - return ''; -} +/// Asserting this is absent is what distinguishes a label that stands in for +/// the tile from one that merely sits alongside it. Only the rendered tree +/// shows the difference, so a check on the [Semantics] widget cannot see it. +String _visibleTextNode(String title, String subtitle) => '$title\n$subtitle'; void main() { // The sheet subtitle dates itself with DateFormat.yMMMd(), which resolves @@ -170,23 +161,88 @@ void main() { testWidgets('the accessibility label names the certification too', ( tester, ) async { + final handle = tester.ensureSemantics(); + await _pumpSheet(tester, [ _makeCert(name: 'Bill Ansell', level: CertificationLevel.diveMaster), ]); // A screen reader must hear the level even when a custom name owns the // title, since the title alone no longer carries it. - expect(_tileSemanticsLabel(tester), 'PADI Bill Ansell, Divemaster'); + expect( + find.bySemanticsLabel('PADI Bill Ansell, Divemaster'), + findsOneWidget, + ); + // And exactly once: without excludeSemantics the tile keeps a second + // node carrying its visible text, so the row is announced twice. + expect( + find.bySemanticsLabel( + _visibleTextNode('Bill Ansell', 'PADI - Divemaster'), + ), + findsNothing, + ); + + handle.dispose(); + }); + + testWidgets('the tile stays activatable through the semantics label', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + Certification? picked; + + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + overrides: [ + certificationListNotifierProvider.overrideWith( + (ref) => _MockCertListNotifier([ + _makeCert( + name: 'Bill Ansell', + level: CertificationLevel.diveMaster, + ), + ]), + ), + ], + child: CertificationPickerSheet( + scrollController: ScrollController(), + selectedCertification: null, + onCertificationSelected: (cert) => picked = cert, + ), + ), + ); + await tester.pump(); + + // Excluding the subtree drops the ListTile's own tap action, so the + // wrapper must carry one or the row becomes unreachable by a screen + // reader even though it still looks tappable. + tester.semantics.performAction( + find.semantics.byLabel('PADI Bill Ansell, Divemaster'), + SemanticsAction.tap, + ); + await tester.pump(); + + expect(picked?.name, 'Bill Ansell'); + + handle.dispose(); }); testWidgets('a derived title does not repeat the level', (tester) async { + final handle = tester.ensureSemantics(); + await _pumpSheet(tester, [ _makeCert(name: '', level: CertificationLevel.diveMaster), ]); expect(_subtitleOf(tester, find.byType(ListTile)), 'PADI'); // The title is already the level, so the label says it once, not twice. - expect(_tileSemanticsLabel(tester), 'PADI Divemaster'); + expect(find.bySemanticsLabel('PADI Divemaster'), findsOneWidget); + expect( + find.bySemanticsLabel(_visibleTextNode('Divemaster', 'PADI')), + findsNothing, + ); + + handle.dispose(); }); }); } From 78b89bc124b15ec3b4c032d6b24044636b1b4d63 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:07:21 -0400 Subject: [PATCH 037/122] fix(version): route every version string through formatAppVersion #1282 added formatAppVersion / formatVersionWithBuild but used them only from the new LogEnvironment code, to keep that diff to one concern. Three call sites still open-coded the same "append the build number as a fourth segment" idiom, and one of them had no guard against doubling it. startup_page built the version unconditionally as '${info.version}.${info.buildNumber}'. PackageInfo.version is not always the three-segment marketing version; where the platform already reports four segments this produced a five-segment string like 1.7.6.123.123. That value is not internal. It is handed to PreMigrationBackupService, stored as BackupRecord.appVersion, and rendered verbatim in every branch of the restore confirmation dialog, where it tells someone which app version produced the backup they are about to restore. A malformed version there misleads at exactly the moment the user is making a destructive decision. settings_page and update_providers already guarded correctly; adopting the helper there is deduplication, and keeps the helper from looking vestigial. update_providers keeps its comment about a version that does not match its release tag comparing as older than its own release. Tests: formatAppVersion now has PackageInfo-level coverage, including the already-four-segment input that is the case no existing test covered. The pin lives on the helper rather than at startup_page's own call site because that branch is unreachable from a widget test by construction: tests supply preMigrationBackupFactory, and the real branch builds a PreMigrationBackupService against the production database path. --- lib/core/presentation/pages/startup_page.dart | 3 +- .../providers/update_providers.dart | 6 +-- .../presentation/pages/settings_page.dart | 5 +-- test/core/utils/app_version_test.dart | 37 +++++++++++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/lib/core/presentation/pages/startup_page.dart b/lib/core/presentation/pages/startup_page.dart index db4458c8d3..4a88e8716a 100644 --- a/lib/core/presentation/pages/startup_page.dart +++ b/lib/core/presentation/pages/startup_page.dart @@ -37,6 +37,7 @@ import 'package:submersion/core/services/security/database_security_sidecar.dart import 'package:submersion/core/services/security/locked_database_escape.dart'; import 'package:submersion/core/services/log_file_service.dart'; import 'package:submersion/core/services/notification_service.dart'; +import 'package:submersion/core/utils/app_version.dart'; import 'package:submersion/features/backup/data/repositories/backup_preferences.dart'; import 'package:submersion/features/backup/data/services/backup_service.dart'; import 'package:submersion/features/backup/data/services/backup_target.dart'; @@ -730,7 +731,7 @@ class _StartupWrapperState extends State appVersion = '0.0.0.0'; } else { final info = await PackageInfo.fromPlatform(); - appVersion = '${info.version}.${info.buildNumber}'; + appVersion = formatAppVersion(info); service = PreMigrationBackupService( livePathProvider: () async => dbPath, // Resolve LAZILY, inside the provider. Resolution arms any diff --git a/lib/features/auto_update/presentation/providers/update_providers.dart b/lib/features/auto_update/presentation/providers/update_providers.dart index 3e08adfd0b..1580a3e70a 100644 --- a/lib/features/auto_update/presentation/providers/update_providers.dart +++ b/lib/features/auto_update/presentation/providers/update_providers.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/app_version.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:submersion/features/auto_update/data/repositories/update_preferences.dart'; @@ -67,10 +68,7 @@ final updateServiceProvider = FutureProvider((ref) async { // Release tags are 4-segment (vX.Y.Z.N) while packageInfo.version is the // 3-segment marketing version; without the build number appended, a // current install always compares as older than its own release tag. - final currentVersion = - packageInfo.version.endsWith('.${packageInfo.buildNumber}') - ? packageInfo.version - : '${packageInfo.version}.${packageInfo.buildNumber}'; + final currentVersion = formatAppVersion(packageInfo); if (_useSparkleEngine) { return SparkleUpdateService(feedUrl: appcastUrlFor(channel)); diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 0b722cb84f..20bdfbc33a 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/core/icons/mdi_icons.dart'; +import 'package:submersion/core/utils/app_version.dart'; import 'package:submersion/core/utils/currency.dart'; import 'package:submersion/core/constants/map_style.dart'; import 'package:submersion/core/deco/entities/cns_calculation_method.dart'; @@ -2998,9 +2999,7 @@ class _AboutSectionContentState extends ConsumerState<_AboutSectionContent> { ref.watch(releaseChannelProvider) == ReleaseChannel.beta; final versionString = packageInfoAsync.when( data: (info) { - final version = info.version.endsWith('.${info.buildNumber}') - ? info.version - : '${info.version}.${info.buildNumber}'; + final version = formatAppVersion(info); final base = context.l10n.settings_about_version(version); return isBetaChannel ? context.l10n.settings_updates_channelBadgeBeta(base) diff --git a/test/core/utils/app_version_test.dart b/test/core/utils/app_version_test.dart index e0f4143e42..c6dcf1e885 100644 --- a/test/core/utils/app_version_test.dart +++ b/test/core/utils/app_version_test.dart @@ -1,6 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:submersion/core/utils/app_version.dart'; +PackageInfo _info({required String version, required String buildNumber}) => + PackageInfo( + appName: 'Submersion', + packageName: 'app.submersion', + version: version, + buildNumber: buildNumber, + ); + void main() { group('formatVersionWithBuild', () { test('appends the build number as a fourth segment', () { @@ -23,4 +32,32 @@ void main() { expect(formatVersionWithBuild('1.7.23', '3'), '1.7.23.3'); }); }); + + group('formatAppVersion', () { + test('appends the build number to a three-segment marketing version', () { + expect( + formatAppVersion(_info(version: '1.7.6', buildNumber: '123')), + '1.7.6.123', + ); + }); + + test('does not double a build number the platform already reported', () { + // The case every caller has to survive: PackageInfo.version is not + // guaranteed to be the three-segment marketing version. Open-coding + // "$version.$buildNumber" here yields the five-segment "1.7.6.123.123", + // which reaches the user in the restore confirmation dialog as the + // version that produced a backup. + expect( + formatAppVersion(_info(version: '1.7.6.123', buildNumber: '123')), + '1.7.6.123', + ); + }); + + test('leaves the version alone when the platform reports no build', () { + expect( + formatAppVersion(_info(version: '1.7.6', buildNumber: '')), + '1.7.6', + ); + }); + }); } From 579de3eb811b05625d2cb69d0af466d09d292e1f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:26:58 -0400 Subject: [PATCH 038/122] feat(media): let the diver pin a media item to a moment in the dive Fixes #1090. A photo or video whose capture time is wrong or missing used to land at the entry or exit of the profile: the enricher returned the first or last profile point as an estimate and the chart clamped the offset into the profile range, so a wrong EXIF date drew a confident marker at the exit and the viewer chip read +1879:28. Add media.manual_elapsed_seconds (schema v162) on the media row so the pin syncs with the row and survives enrichment recomputes; the enricher positions a pinned item from the pin via calculateEnrichmentAtElapsed and reports MatchConfidence.manual. Introduce MediaDiveWindow (30 min before, 60 min after; DivePhotoMatcher's buffers alias it) and MediaEnrichment.isWithinDiveWindow as the one rule the chart markers, the 3D scene, the viewer overlay and the info panel use: automatic positions beyond the tolerance are dropped instead of clamped, manual ones always pass. Add SetMediaTimeDialog (mm:ss field, slider, live mini profile preview, Reset to automatic), reachable from the viewer's elapsed chip and a Set time in dive action in the media info panel, applied through MediaTimePinner as one row write plus one enrichment pass. Strings in all eleven locales. --- lib/core/database/database.dart | 35 ++- .../domain/entities/dive_3d_scene_data.dart | 8 +- .../widgets/photo_marker_layout.dart | 6 +- .../data/repositories/media_repository.dart | 37 ++++ .../data/repositories/media_row_mapper.dart | 1 + .../data/services/dive_media_enricher.dart | 19 +- .../data/services/enrichment_service.dart | 35 +++ .../domain/entities/media_dive_window.dart | 41 ++++ .../media/domain/entities/media_item.dart | 43 +++- .../domain/services/dive_photo_matcher.dart | 5 +- .../helpers/elapsed_time_format.dart | 25 +++ .../helpers/media_time_choice.dart | 16 ++ .../helpers/media_time_pinner.dart | 31 +++ .../presentation/pages/media_viewer_page.dart | 137 ++++++++++-- .../providers/media_providers.dart | 10 + .../widgets/media_info_panel.dart | 73 ++++++- .../widgets/set_media_time_dialog.dart | 172 +++++++++++++++ lib/l10n/arb/app_ar.arb | 11 + lib/l10n/arb/app_de.arb | 11 + lib/l10n/arb/app_en.arb | 43 ++++ lib/l10n/arb/app_es.arb | 11 + lib/l10n/arb/app_fr.arb | 11 + lib/l10n/arb/app_he.arb | 11 + lib/l10n/arb/app_hu.arb | 11 + lib/l10n/arb/app_it.arb | 11 + lib/l10n/arb/app_localizations.dart | 66 ++++++ lib/l10n/arb/app_localizations_ar.dart | 39 ++++ lib/l10n/arb/app_localizations_de.dart | 39 ++++ lib/l10n/arb/app_localizations_en.dart | 39 ++++ lib/l10n/arb/app_localizations_es.dart | 40 ++++ lib/l10n/arb/app_localizations_fr.dart | 40 ++++ lib/l10n/arb/app_localizations_he.dart | 39 ++++ lib/l10n/arb/app_localizations_hu.dart | 39 ++++ lib/l10n/arb/app_localizations_it.dart | 41 ++++ lib/l10n/arb/app_localizations_nl.dart | 39 ++++ lib/l10n/arb/app_localizations_pt.dart | 39 ++++ lib/l10n/arb/app_localizations_zh.dart | 39 ++++ lib/l10n/arb/app_nl.arb | 11 + lib/l10n/arb/app_pt.arb | 11 + lib/l10n/arb/app_zh.arb | 11 + ...ration_v162_media_manual_elapsed_test.dart | 92 ++++++++ .../entities/dive_3d_scene_data_test.dart | 54 +++++ .../widgets/photo_marker_layout_test.dart | 47 +++++ .../media_repository_manual_elapsed_test.dart | 116 ++++++++++ .../services/dive_media_enricher_test.dart | 87 ++++++++ .../services/enrichment_service_test.dart | 67 ++++++ .../entities/media_dive_window_test.dart | 140 ++++++++++++ .../media_item_manual_elapsed_test.dart | 38 ++++ .../helpers/elapsed_time_format_test.dart | 47 +++++ .../helpers/media_time_pinner_test.dart | 111 ++++++++++ .../pages/media_viewer_manual_time_test.dart | 199 ++++++++++++++++++ .../widgets/media_info_panel_test.dart | 133 ++++++++++++ .../widgets/set_media_time_dialog_test.dart | 160 ++++++++++++++ 53 files changed, 2602 insertions(+), 35 deletions(-) create mode 100644 lib/features/media/domain/entities/media_dive_window.dart create mode 100644 lib/features/media/presentation/helpers/elapsed_time_format.dart create mode 100644 lib/features/media/presentation/helpers/media_time_choice.dart create mode 100644 lib/features/media/presentation/helpers/media_time_pinner.dart create mode 100644 lib/features/media/presentation/widgets/set_media_time_dialog.dart create mode 100644 test/core/database/migration_v162_media_manual_elapsed_test.dart create mode 100644 test/features/media/data/repositories/media_repository_manual_elapsed_test.dart create mode 100644 test/features/media/domain/entities/media_dive_window_test.dart create mode 100644 test/features/media/domain/entities/media_item_manual_elapsed_test.dart create mode 100644 test/features/media/presentation/helpers/elapsed_time_format_test.dart create mode 100644 test/features/media/presentation/helpers/media_time_pinner_test.dart create mode 100644 test/features/media/presentation/pages/media_viewer_manual_time_test.dart create mode 100644 test/features/media/presentation/widgets/set_media_time_dialog_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 92124f1a74..8f8b39d365 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1333,6 +1333,11 @@ class Media extends Table { // where it is true. Synced with the row like every other media column. BoolColumn get retainInLibrary => boolean().withDefault(const Constant(false))(); + // v162: the moment in the dive the diver pinned this item to, in seconds + // from the dive start (issue #1090). Null means the position derives from + // taken_at. Lives on the media row, not on media_enrichment, so it syncs + // with the row and survives every enrichment recompute. + IntColumn get manualElapsedSeconds => integer().nullable()(); // coverage:ignore-end IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); @@ -3165,7 +3170,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 = 162; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3450,6 +3455,9 @@ 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, + // v162: media.manual_elapsed_seconds, the diver's own placement of a + // media item in the dive when its capture time is wrong (issue #1090). + 162, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4202,6 +4210,21 @@ class AppDatabase extends _$AppDatabase { } } + /// v162: media.manual_elapsed_seconds (issue #1090). Idempotent; safe to + /// call from both onUpgrade and the beforeOpen backstop. Nullable with no + /// default, so every pre-existing row reads back as "position from + /// taken_at". + Future _assertMediaManualElapsedColumn() async { + final cols = await customSelect("PRAGMA table_info('media')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('manual_elapsed_seconds')) { + await customStatement( + 'ALTER TABLE media ADD COLUMN manual_elapsed_seconds INTEGER', + ); + } + } + /// v111: equipment_sets.is_default column + equipment_set_geofences table. /// Idempotent (createTable is IF NOT EXISTS; the ALTER is PRAGMA-guarded) so /// it is safe to call from both onUpgrade and the beforeOpen backstop. @@ -8535,6 +8558,11 @@ class AppDatabase extends _$AppDatabase { await _assertO2CellMvDefaultColumn(); } if (from < 161) await reportProgress(); + // v162: media.manual_elapsed_seconds (issue #1090). + if (from < 162) { + await _assertMediaManualElapsedColumn(); + } + if (from < 162) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -8729,6 +8757,11 @@ class AppDatabase extends _$AppDatabase { // (issue #1235; same parallel-branch version-collision self-heal). await _assertO2CellMvDefaultColumn(); + // v162 backstop: re-assert media.manual_elapsed_seconds (issue + // #1090; same parallel-branch version-collision self-heal). The + // media row mapper reads it on every hydration. + await _assertMediaManualElapsedColumn(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); diff --git a/lib/features/dive_3d/domain/entities/dive_3d_scene_data.dart b/lib/features/dive_3d/domain/entities/dive_3d_scene_data.dart index 18b5186926..e1f2f48815 100644 --- a/lib/features/dive_3d/domain/entities/dive_3d_scene_data.dart +++ b/lib/features/dive_3d/domain/entities/dive_3d_scene_data.dart @@ -75,9 +75,15 @@ class Dive3dSceneData { for (final e in events) if (e.eventType == ProfileEventType.bookmark) e, ], + // Same dive-window rule as the profile chart's marker builder, so a + // wrong-dated photo is not drawn at the surface here either. photos: [ for (final m in photos) - if (m.enrichment?.elapsedSeconds != null) m, + if (m.enrichment?.isWithinDiveWindow( + sorted.isEmpty ? 0 : sorted.last.timestamp, + ) ?? + false) + m, ], durationSeconds: sorted.isEmpty ? 0 : sorted.last.timestamp.toDouble(), maxDepthMeters: maxDepth, diff --git a/lib/features/dive_log/presentation/widgets/photo_marker_layout.dart b/lib/features/dive_log/presentation/widgets/photo_marker_layout.dart index d045e1a375..d021ec8ba7 100644 --- a/lib/features/dive_log/presentation/widgets/photo_marker_layout.dart +++ b/lib/features/dive_log/presentation/widgets/photo_marker_layout.dart @@ -23,7 +23,10 @@ class PhotoChartMarker { /// Builds chart markers from a dive's media list, time-sorted. Photos and /// videos with a usable profile position are included; elapsed time is -/// clamped to the profile range to absorb entry/exit clock skew. +/// clamped to the profile range to absorb entry/exit clock skew, but only +/// inside the [MediaDiveWindow] tolerance: a capture time days outside the +/// dive is dropped rather than pinned to the exit (issue #1090). A manual +/// position always passes. List photoMarkersFromMedia( List media, { required int maxProfileSeconds, @@ -37,6 +40,7 @@ List photoMarkersFromMedia( final enrichment = item.enrichment; if (enrichment == null) continue; if (enrichment.matchConfidence == MatchConfidence.noProfile) continue; + if (!enrichment.isWithinDiveWindow(maxProfileSeconds)) continue; final seconds = enrichment.elapsedSeconds; final depth = enrichment.depthMeters; if (seconds == null || depth == null) continue; diff --git a/lib/features/media/data/repositories/media_repository.dart b/lib/features/media/data/repositories/media_repository.dart index 92f9c23d6a..7f050ae6df 100644 --- a/lib/features/media/data/repositories/media_repository.dart +++ b/lib/features/media/data/repositories/media_repository.dart @@ -271,6 +271,7 @@ class MediaRepository { item.remoteCompressedUploadedAt?.millisecondsSinceEpoch, ), retainInLibrary: Value(item.retainInLibrary), + manualElapsedSeconds: Value(item.manualElapsedSeconds), createdAt: Value(now.millisecondsSinceEpoch), updatedAt: Value(now.millisecondsSinceEpoch), ), @@ -300,6 +301,41 @@ class MediaRepository { } } + /// Pins [id] to [elapsedSeconds] from its dive's start, or clears the pin + /// with null so the position derives from the capture time again + /// (issue #1090). + /// + /// Writes only the pin and updatedAt, so a stale caller snapshot cannot + /// clobber any other column. The enrichment row is NOT rewritten here: + /// [DiveMediaEnricher] is the one writer of enrichment and reads the pin + /// on its next pass, which callers trigger right after this returns. + Future setManualElapsedSeconds(String id, int? elapsedSeconds) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update(_db.media)..where((t) => t.id.equals(id))).write( + MediaCompanion( + manualElapsedSeconds: Value(elapsedSeconds), + updatedAt: Value(now), + ), + ); + _log.info('Set manual elapsed for media $id: $elapsedSeconds'); + + await _syncRepository.markRecordPending( + entityType: 'media', + recordId: id, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to set manual elapsed for media: $id', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Update existing media Future updateMedia(domain.MediaItem item) async { try { @@ -353,6 +389,7 @@ class MediaRepository { item.remoteCompressedUploadedAt?.millisecondsSinceEpoch, ), retainInLibrary: Value(item.retainInLibrary), + manualElapsedSeconds: Value(item.manualElapsedSeconds), updatedAt: Value(now), ), ); diff --git a/lib/features/media/data/repositories/media_row_mapper.dart b/lib/features/media/data/repositories/media_row_mapper.dart index bcdf33f364..5cbd6a03d6 100644 --- a/lib/features/media/data/repositories/media_row_mapper.dart +++ b/lib/features/media/data/repositories/media_row_mapper.dart @@ -73,6 +73,7 @@ domain.MediaItem mediaItemFromRow( ? DateTime.fromMillisecondsSinceEpoch(row.remoteCompressedUploadedAt!) : null, retainInLibrary: row.retainInLibrary, + manualElapsedSeconds: row.manualElapsedSeconds, createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt), enrichment: enrichmentRow != null diff --git a/lib/features/media/data/services/dive_media_enricher.dart b/lib/features/media/data/services/dive_media_enricher.dart index d6e23db52c..ffb00d40d9 100644 --- a/lib/features/media/data/services/dive_media_enricher.dart +++ b/lib/features/media/data/services/dive_media_enricher.dart @@ -63,11 +63,20 @@ class DiveMediaEnricher { // chart excludes them regardless — don't fabricate a depth/time for one. if (item.mediaType == MediaType.instructorSignature) continue; - final result = enrichmentService.calculateEnrichment( - profile: dive.profile, - diveStartTime: dive.effectiveEntryTime, - photoTime: item.takenAt, - ); + // A pinned item (issue #1090) is positioned from the diver's offset, + // never from its capture time, so a backfill converges on the pin + // instead of reverting it. + final manual = item.manualElapsedSeconds; + final result = manual != null + ? enrichmentService.calculateEnrichmentAtElapsed( + profile: dive.profile, + elapsedSeconds: manual, + ) + : enrichmentService.calculateEnrichment( + profile: dive.profile, + diveStartTime: dive.effectiveEntryTime, + photoTime: item.takenAt, + ); // Mirror the gallery path: don't persist a row we couldn't actually // place (no depth and no usable profile match). An existing row is left diff --git a/lib/features/media/data/services/enrichment_service.dart b/lib/features/media/data/services/enrichment_service.dart index 5213e3fd88..f93b73788a 100644 --- a/lib/features/media/data/services/enrichment_service.dart +++ b/lib/features/media/data/services/enrichment_service.dart @@ -79,7 +79,42 @@ class EnrichmentService { final elapsedSeconds = normalizedPhoto .difference(normalizedStart) .inSeconds; + return _enrichAtElapsed(profile: profile, elapsedSeconds: elapsedSeconds); + } + + /// Calculate enrichment data for a media item the diver pinned to a moment + /// in the dive (issue #1090). + /// + /// [elapsedSeconds] is the diver's chosen offset from the dive start; no + /// capture time is involved, so no wall-clock normalisation runs. Depth and + /// temperature come from the same profile lookup as [calculateEnrichment], + /// but the confidence is always [MatchConfidence.manual] (or + /// [MatchConfidence.noProfile] when there is nothing to read), because the + /// diver's placement is a statement, not an estimate. + EnrichmentResult calculateEnrichmentAtElapsed({ + required List profile, + required int elapsedSeconds, + }) { + final result = _enrichAtElapsed( + profile: profile, + elapsedSeconds: elapsedSeconds, + ); + if (result.matchConfidence == MatchConfidence.noProfile) return result; + return EnrichmentResult( + depthMeters: result.depthMeters, + temperatureCelsius: result.temperatureCelsius, + elapsedSeconds: result.elapsedSeconds, + matchConfidence: MatchConfidence.manual, + timestampOffsetSeconds: result.timestampOffsetSeconds, + ); + } + /// Profile lookup shared by both entry points: the point (or interpolation) + /// at [elapsedSeconds] from the dive start, with the automatic confidence. + EnrichmentResult _enrichAtElapsed({ + required List profile, + required int elapsedSeconds, + }) { // Handle empty profile if (profile.isEmpty) { return EnrichmentResult( diff --git a/lib/features/media/domain/entities/media_dive_window.dart b/lib/features/media/domain/entities/media_dive_window.dart new file mode 100644 index 0000000000..dc4a085f55 --- /dev/null +++ b/lib/features/media/domain/entities/media_dive_window.dart @@ -0,0 +1,41 @@ +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +/// The tolerance around a dive's profile inside which an automatically +/// derived media position is still trusted. +/// +/// A capture time a few minutes past the exit is a surface shot and belongs +/// pinned to the end of the profile; a capture time days away (a camera +/// with an unset clock, a file whose only date is its copy-to-disk time) is +/// not knowledge about the dive at all, and drawing it at the exit invents +/// a position the diver never chose (issue #1090). The buffers are the same +/// ones [DivePhotoMatcher] uses to decide a file belongs to a dive in the +/// first place, so a file that matched by time is never dropped here. +class MediaDiveWindow { + const MediaDiveWindow._(); + + /// The dive's length as the chart measures it: the last sample's offset, + /// or 0 for a dive with no profile. + static int profileLengthSeconds(List profile) { + var length = 0; + for (final point in profile) { + if (point.timestamp > length) length = point.timestamp; + } + return length; + } + + /// Slack before the profile start: boat, dock and pre-descent shots. + static const Duration before = Duration(minutes: 30); + + /// Slack after the profile end: surface-interval and debrief shots. + static const Duration after = Duration(minutes: 60); + + /// Whether [elapsedSeconds] from the dive start lies inside the profile of + /// [profileLengthSeconds], widened by [before] and [after]. + static bool contains({ + required int elapsedSeconds, + required int profileLengthSeconds, + }) { + return elapsedSeconds >= -before.inSeconds && + elapsedSeconds <= profileLengthSeconds + after.inSeconds; + } +} diff --git a/lib/features/media/domain/entities/media_item.dart b/lib/features/media/domain/entities/media_item.dart index 0ee8ec0ebb..075b6f2a03 100644 --- a/lib/features/media/domain/entities/media_item.dart +++ b/lib/features/media/domain/entities/media_item.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:equatable/equatable.dart'; +import 'package:submersion/features/media/domain/entities/media_dive_window.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; /// Type of media (photo, video, instructor signature) @@ -37,7 +38,13 @@ enum MatchConfidence { exact, interpolated, estimated, - noProfile; + noProfile, + + /// The diver pinned the item to a moment in the dive themselves + /// ([MediaItem.manualElapsedSeconds]); depth and temperature are read + /// from the profile at that offset. Never an estimate, never reverted + /// by a backfill, and never subject to the dive-window tolerance. + manual; String get displayName { switch (this) { @@ -49,6 +56,8 @@ enum MatchConfidence { return 'Estimated'; case MatchConfidence.noProfile: return 'No Profile'; + case MatchConfidence.manual: + return 'Manual'; } } @@ -106,6 +115,12 @@ class MediaItem extends Equatable { /// The orphan sweep never GCs retained rows' store blobs. final bool retainInLibrary; + /// Seconds from the dive start the diver pinned this item to, overriding + /// the position derived from [takenAt] (issue #1090). Null means the + /// automatic position applies. [takenAt] itself is never rewritten: it is + /// the file's own timestamp and gallery re-resolution matches on it. + final int? manualElapsedSeconds; + final DateTime createdAt; final DateTime updatedAt; final MediaEnrichment? enrichment; @@ -150,6 +165,7 @@ class MediaItem extends Equatable { this.compressedSizeBytes, this.remoteCompressedUploadedAt, this.retainInLibrary = false, + this.manualElapsedSeconds, required this.createdAt, required this.updatedAt, this.enrichment, @@ -283,6 +299,7 @@ class MediaItem extends Equatable { Object? compressedSizeBytes = _undefined, Object? remoteCompressedUploadedAt = _undefined, bool? retainInLibrary, + Object? manualElapsedSeconds = _undefined, DateTime? createdAt, DateTime? updatedAt, Object? enrichment = _undefined, @@ -371,6 +388,9 @@ class MediaItem extends Equatable { ? this.remoteCompressedUploadedAt : remoteCompressedUploadedAt as DateTime?, retainInLibrary: retainInLibrary ?? this.retainInLibrary, + manualElapsedSeconds: manualElapsedSeconds == _undefined + ? this.manualElapsedSeconds + : manualElapsedSeconds as int?, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, enrichment: enrichment == _undefined @@ -420,6 +440,7 @@ class MediaItem extends Equatable { compressedSizeBytes, remoteCompressedUploadedAt, retainInLibrary, + manualElapsedSeconds, createdAt, updatedAt, enrichment, @@ -482,6 +503,26 @@ class MediaEnrichment extends Equatable { ); } + /// Whether the diver placed this item in the dive themselves. + bool get isManual => matchConfidence == MatchConfidence.manual; + + /// Whether this row positions the item somewhere the chart should draw. + /// + /// An automatic position is only trusted inside [MediaDiveWindow] around a + /// profile of [profileLengthSeconds]; a manual one always is. The chart, + /// the 3D scene and the viewer all ask this rather than clamping blindly, + /// so a wrong capture date cannot pin a marker to the exit (issue #1090). + bool isWithinDiveWindow(int profileLengthSeconds) { + final seconds = elapsedSeconds; + if (seconds == null) return false; + if (matchConfidence == MatchConfidence.noProfile) return false; + if (isManual) return true; + return MediaDiveWindow.contains( + elapsedSeconds: seconds, + profileLengthSeconds: profileLengthSeconds, + ); + } + @override List get props => [ id, diff --git a/lib/features/media/domain/services/dive_photo_matcher.dart b/lib/features/media/domain/services/dive_photo_matcher.dart index 5d4f90c955..ea834679ba 100644 --- a/lib/features/media/domain/services/dive_photo_matcher.dart +++ b/lib/features/media/domain/services/dive_photo_matcher.dart @@ -1,3 +1,4 @@ +import 'package:submersion/features/media/domain/entities/media_dive_window.dart'; import 'package:submersion/features/media/domain/value_objects/extracted_file.dart'; import 'package:submersion/features/media/domain/value_objects/matched_selection.dart'; @@ -36,11 +37,11 @@ class DivePhotoMatcher { /// Pre-dive buffer applied before [DiveBounds.entryTime] when computing /// the match window. Catches photos taken at the boat / dock / on the /// surface before the descent. - static const Duration preBuffer = Duration(minutes: 30); + static const Duration preBuffer = MediaDiveWindow.before; /// Post-dive buffer applied after [DiveBounds.exitTime] when computing /// the match window. Catches surface-interval shots, debrief photos. - static const Duration postBuffer = Duration(minutes: 60); + static const Duration postBuffer = MediaDiveWindow.after; /// Routes [files] to [dives] by EXIF date. MatchedSelection match({ diff --git a/lib/features/media/presentation/helpers/elapsed_time_format.dart b/lib/features/media/presentation/helpers/elapsed_time_format.dart new file mode 100644 index 0000000000..f31d59816f --- /dev/null +++ b/lib/features/media/presentation/helpers/elapsed_time_format.dart @@ -0,0 +1,25 @@ +/// Formats [seconds] from the dive start as `m:ss`, minutes unpadded so an +/// hour-plus dive reads `75:00` rather than rolling into hours. Shared by the +/// Set-time dialog's field and the viewer's elapsed chip (issue #1090), so +/// what the diver types is what they later see. +String formatElapsedMmSs(int seconds) { + final sign = seconds < 0 ? '-' : ''; + final abs = seconds.abs(); + final minutes = abs ~/ 60; + final secs = abs % 60; + return '$sign$minutes:${secs.toString().padLeft(2, '0')}'; +} + +final _mmSs = RegExp(r'^(\d+)(?::([0-5]\d))?$'); + +/// Parses `m:ss`, `mm:ss` or bare minutes into seconds from the dive start. +/// +/// Returns null for anything else, including a seconds field of 60 or more +/// and a leading sign: the dialog offers only moments inside the dive. +int? parseElapsedMmSs(String input) { + final match = _mmSs.firstMatch(input.trim()); + if (match == null) return null; + final minutes = int.parse(match.group(1)!); + final seconds = int.parse(match.group(2) ?? '0'); + return minutes * 60 + seconds; +} diff --git a/lib/features/media/presentation/helpers/media_time_choice.dart b/lib/features/media/presentation/helpers/media_time_choice.dart new file mode 100644 index 0000000000..8b76fa7377 --- /dev/null +++ b/lib/features/media/presentation/helpers/media_time_choice.dart @@ -0,0 +1,16 @@ +/// What the diver decided in the Set-time dialog (issue #1090). +sealed class MediaTimeChoice { + const MediaTimeChoice(); +} + +/// Pin the item to [elapsedSeconds] from the dive start. +class MediaTimePinned extends MediaTimeChoice { + const MediaTimePinned(this.elapsedSeconds); + + final int elapsedSeconds; +} + +/// Drop the pin so the position derives from the capture time again. +class MediaTimeReset extends MediaTimeChoice { + const MediaTimeReset(); +} diff --git a/lib/features/media/presentation/helpers/media_time_pinner.dart b/lib/features/media/presentation/helpers/media_time_pinner.dart new file mode 100644 index 0000000000..1be481e51c --- /dev/null +++ b/lib/features/media/presentation/helpers/media_time_pinner.dart @@ -0,0 +1,31 @@ +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/dive_media_enricher.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/presentation/helpers/media_time_choice.dart'; + +/// Applies a [MediaTimeChoice] to a media item (issue #1090). +/// +/// One write to the media row, then one enrichment pass over its dive. The +/// enricher is the only writer of enrichment rows and reads the pin on that +/// pass, so the chart, the 3D scene and the viewer all reposition on the +/// next media tick instead of waiting for a later backfill. Rows that +/// already match are left alone by the enricher, so the pass costs one +/// write. +class MediaTimePinner { + const MediaTimePinner({required this.repository, required this.enricher}); + + final MediaRepository repository; + final DiveMediaEnricher enricher; + + Future apply(MediaItem item, MediaTimeChoice choice) async { + final diveId = item.diveId; + // A moment in a dive needs a dive; nothing to pin against otherwise. + if (diveId == null) return; + final elapsedSeconds = switch (choice) { + MediaTimePinned(:final elapsedSeconds) => elapsedSeconds, + MediaTimeReset() => null, + }; + await repository.setManualElapsedSeconds(item.id, elapsedSeconds); + await enricher.enrichMissingForDive(diveId); + } +} diff --git a/lib/features/media/presentation/pages/media_viewer_page.dart b/lib/features/media/presentation/pages/media_viewer_page.dart index cc56b26abd..6d365160eb 100644 --- a/lib/features/media/presentation/pages/media_viewer_page.dart +++ b/lib/features/media/presentation/pages/media_viewer_page.dart @@ -20,8 +20,10 @@ import 'package:submersion/features/dive_log/presentation/providers/dive_provide import 'package:submersion/features/dive_log/presentation/providers/gas_switch_providers.dart'; import 'package:submersion/features/dive_log/presentation/providers/profile_analysis_provider.dart'; import 'package:submersion/features/media/data/services/metadata_write_service.dart'; +import 'package:submersion/features/media/domain/entities/media_dive_window.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/presentation/helpers/elapsed_time_format.dart'; import 'package:submersion/features/media/presentation/helpers/media_share_helper.dart'; import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; @@ -33,6 +35,7 @@ import 'package:submersion/features/media/presentation/widgets/perdix_overlay/pe import 'package:submersion/features/media/presentation/widgets/write_metadata_dialog.dart'; import 'package:submersion/features/media/presentation/widgets/mini_dive_profile_overlay.dart'; import 'package:submersion/features/media/presentation/widgets/media_info_sheet.dart'; +import 'package:submersion/features/media/presentation/widgets/set_media_time_dialog.dart'; import 'package:submersion/features/media_store/presentation/widgets/media_reupload_button.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -143,6 +146,33 @@ class _MediaViewerPageState extends ConsumerState { MediaItem _hydrate(MediaItem item) => ref.watch(mediaByIdProvider(item.id)).value ?? item; + /// Opens the Set-time dialog for [item] and applies the diver's choice + /// (issue #1090). The viewer re-reads the row on the media tick the write + /// raises, so the chips, the mini profile and the face all move at once. + Future _setTimeInDive( + MediaItem item, + List profile, + AppSettings settings, + ) async { + final enrichment = item.enrichment; + final positioned = + enrichment?.isWithinDiveWindow( + MediaDiveWindow.profileLengthSeconds(profile), + ) ?? + false; + final choice = await showSetMediaTimeDialog( + context, + profile: profile, + initialElapsedSeconds: + item.manualElapsedSeconds ?? + (positioned ? enrichment!.elapsedSeconds! : 0), + isPinned: item.manualElapsedSeconds != null, + settings: settings, + ); + if (choice == null || !mounted) return; + await ref.read(mediaTimePinnerProvider).apply(item, choice); + } + /// Computes and saves the missing [MediaEnrichment] rows for [diveId], the /// same idempotent backfill dive detail runs on open. /// @@ -301,12 +331,17 @@ class _MediaViewerPageState extends ConsumerState { ); final dive = diveAsync.value; + final profileLength = MediaDiveWindow.profileLengthSeconds( + diveProfile ?? const [], + ); // Whether this item is synced to a moment in the profile, decided - // from the enrichment alone. Media that is not synced can never - // show the Perdix face, whatever the analysis would say. - final perdixPrecondition = - enrichment?.elapsedSeconds != null && - enrichment!.matchConfidence != MatchConfidence.noProfile; + // from the enrichment alone (inside the dive-window tolerance, or + // pinned by the diver). Media that is not synced can never show + // the Perdix face, whatever the analysis would say. + final positioned = + enrichment != null && + enrichment.isWithinDiveWindow(profileLength); + final perdixPrecondition = positioned; // Same source-aware profile/analysis pairing as the fullscreen // profile page: analysis curves are read by index, so the profile @@ -482,10 +517,10 @@ class _MediaViewerPageState extends ConsumerState { // Mini dive profile overlay (lower right) if (diveProfile != null && diveProfile.isNotEmpty && - enrichment?.elapsedSeconds != null) + positioned) PositionedMiniProfileOverlay( profile: diveProfile, - photoElapsedSeconds: enrichment!.elapsedSeconds!, + photoElapsedSeconds: enrichment.elapsedSeconds!, photoDepthMeters: enrichment.depthMeters, settings: settings, visible: _showOverlay, @@ -495,6 +530,16 @@ class _MediaViewerPageState extends ConsumerState { _BottomMetadataOverlay( item: currentItem, settings: settings, + profileLengthSeconds: profileLength, + // Pinning needs a profile to pin against; a dive with + // none has no moments to choose from. + onSetTime: diveProfile == null || diveProfile.isEmpty + ? null + : () => _setTimeInDive( + currentItem, + diveProfile, + settings, + ), siteName: diveAsync.whenOrNull( data: (dive) => dive?.site?.name, ), @@ -1411,15 +1456,31 @@ class _BottomMetadataOverlay extends StatelessWidget { final AppSettings settings; final String? siteName; + /// Length of the dive's profile, for the dive-window tolerance. + final int profileLengthSeconds; + + /// Opens the Set-time dialog (issue #1090); null when there is no profile + /// to pin against, which also hides the affordance. + final VoidCallback? onSetTime; + const _BottomMetadataOverlay({ required this.item, required this.settings, + required this.profileLengthSeconds, + this.onSetTime, this.siteName, }); @override Widget build(BuildContext context) { - final enrichment = item.enrichment; + final l10n = context.l10n; + // Depth, temperature and the elapsed chip describe a moment in the + // dive, so they only render for a position inside the dive window (or + // pinned by the diver). Outside it the raw offset used to print as + // `+1879:28`; now the chip says the time is unknown and offers the fix. + final positioned = + item.enrichment?.isWithinDiveWindow(profileLengthSeconds) ?? false; + final enrichment = positioned ? item.enrichment : null; final formatter = UnitFormatter(settings); final timeFormat = DateFormat.jm(); final dateFormat = DateFormat.yMMMd(); @@ -1497,13 +1558,25 @@ class _BottomMetadataOverlay extends StatelessWidget { const SizedBox(width: 16), ], - // Elapsed time - if (enrichment?.elapsedSeconds != null) ...[ + // Elapsed time: the diver's pin, the automatic + // position, or an explicit unknown for a linked item + // whose capture time fell outside the dive. + if (enrichment?.elapsedSeconds != null) + _MetadataChip( + icon: enrichment!.isManual + ? Icons.push_pin_outlined + : Icons.timer_outlined, + value: _formatElapsedTime(enrichment.elapsedSeconds!), + onTap: onSetTime, + tooltip: l10n.media_timeInDive_setAction, + ) + else if (item.diveId != null && onSetTime != null) _MetadataChip( - icon: Icons.timer_outlined, - value: _formatElapsedTime(enrichment!.elapsedSeconds!), + icon: Icons.timer_off_outlined, + value: l10n.media_timeInDive_unknown, + onTap: onSetTime, + tooltip: l10n.media_timeInDive_setAction, ), - ], ], ), @@ -1520,11 +1593,14 @@ class _BottomMetadataOverlay extends StatelessWidget { ), ), - // Confidence indicator + // Confidence indicator. A manual position is the + // diver's own statement, not an estimate, so it gets + // the pin icon on the chip instead of a warning here. if (enrichment != null && enrichment.matchConfidence != MatchConfidence.exact && enrichment.matchConfidence != - MatchConfidence.interpolated) ...[ + MatchConfidence.interpolated && + !enrichment.isManual) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( @@ -1555,11 +1631,7 @@ class _BottomMetadataOverlay extends StatelessWidget { ); } - String _formatElapsedTime(int seconds) { - final minutes = seconds ~/ 60; - final secs = seconds % 60; - return '+$minutes:${secs.toString().padLeft(2, '0')}'; - } + String _formatElapsedTime(int seconds) => '+${formatElapsedMmSs(seconds)}'; } /// Small metadata chip with icon and value. @@ -1567,11 +1639,20 @@ class _MetadataChip extends StatelessWidget { final IconData icon; final String value; - const _MetadataChip({required this.icon, required this.value}); + /// Makes the chip an action (the elapsed chip opens the Set-time dialog). + final VoidCallback? onTap; + final String? tooltip; + + const _MetadataChip({ + required this.icon, + required this.value, + this.onTap, + this.tooltip, + }); @override Widget build(BuildContext context) { - return Row( + final row = Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: Colors.white, size: 18), @@ -1586,5 +1667,17 @@ class _MetadataChip extends StatelessWidget { ), ], ); + if (onTap == null) return row; + final tappable = InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: row, + ), + ); + return tooltip == null + ? tappable + : Tooltip(message: tooltip!, child: tappable); } } diff --git a/lib/features/media/presentation/providers/media_providers.dart b/lib/features/media/presentation/providers/media_providers.dart index a9b533f08d..d89995780a 100644 --- a/lib/features/media/presentation/providers/media_providers.dart +++ b/lib/features/media/presentation/providers/media_providers.dart @@ -4,6 +4,7 @@ import 'package:submersion/features/media/data/repositories/media_repository.dar import 'package:submersion/features/media/data/services/dive_media_enricher.dart'; import 'package:submersion/features/media/data/services/media_unlink_service.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/presentation/helpers/media_time_pinner.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; /// Repository provider (singleton) @@ -36,6 +37,15 @@ final diveMediaEnricherProvider = Provider((ref) { ); }); +/// Applies the Set-time dialog's choice (issue #1090): one media-row write +/// plus one enrichment pass, so the new position lands on the next tick. +final mediaTimePinnerProvider = Provider((ref) { + return MediaTimePinner( + repository: ref.watch(mediaRepositoryProvider), + enricher: ref.watch(diveMediaEnricherProvider), + ); +}); + /// The one implementation of "unlink from a dive", shared by the Media /// section's selection bar and dive detail's. /// diff --git a/lib/features/media/presentation/widgets/media_info_panel.dart b/lib/features/media/presentation/widgets/media_info_panel.dart index 15f4cd9f6d..44c600d038 100644 --- a/lib/features/media/presentation/widgets/media_info_panel.dart +++ b/lib/features/media/presentation/widgets/media_info_panel.dart @@ -5,16 +5,21 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_log/presentation/widgets/dive_detail_row.dart'; +import 'package:submersion/features/media/domain/entities/media_dive_window.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_provenance.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; +import 'package:submersion/features/media/presentation/helpers/elapsed_time_format.dart'; import 'package:submersion/features/media/presentation/helpers/media_link_replacer.dart'; import 'package:submersion/features/media/presentation/providers/media_provenance_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_serving_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/set_media_time_dialog.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -111,16 +116,35 @@ class _Section extends StatelessWidget { } } -class _FileSection extends StatelessWidget { +class _FileSection extends ConsumerWidget { const _FileSection({required this.item, required this.units}); final MediaItem item; final UnitFormatter units; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final unknown = l10n.media_info_unknown; + // The dive's profile decides whether the stored position is inside the + // dive window and gives the Set-time dialog its range (issue #1090). + final diveId = item.diveId; + final profile = diveId == null + ? const [] + : ref.watch(diveProvider(diveId)).value?.profile ?? + const []; + final profileLength = MediaDiveWindow.profileLengthSeconds(profile); + final enrichment = item.enrichment; + final positioned = enrichment?.isWithinDiveWindow(profileLength) ?? false; + final String timeInDive; + if (!positioned) { + timeInDive = unknown; + } else { + final formatted = formatElapsedMmSs(enrichment!.elapsedSeconds!); + timeInDive = enrichment.isManual + ? l10n.media_timeInDive_manual(formatted) + : formatted; + } final width = item.width; final height = item.height; final size = item.contentSizeBytes; @@ -129,6 +153,17 @@ class _FileSection extends StatelessWidget { return _Section( title: l10n.media_info_fileSection, + actions: [ + // Pinning needs a profile to pin against. + if (diveId != null && profile.isNotEmpty) + _SetTimeButton( + item: item, + profile: profile, + initialElapsedSeconds: + item.manualElapsedSeconds ?? + (positioned ? enrichment!.elapsedSeconds! : 0), + ), + ], children: [ DiveDetailRow( label: l10n.media_info_filename, @@ -155,6 +190,8 @@ class _FileSection extends StatelessWidget { // takenAt is non-nullable on the row, so there is no unknown case. value: units.formatDateTime(item.takenAt, l10n: l10n), ), + if (diveId != null) + DiveDetailRow(label: l10n.media_timeInDive_label, value: timeInDive), if (lat != null && lon != null) DiveDetailRow( label: l10n.media_info_coordinates, @@ -165,6 +202,38 @@ class _FileSection extends StatelessWidget { } } +/// Opens the Set-time dialog and applies the diver's choice (issue #1090). +class _SetTimeButton extends ConsumerWidget { + const _SetTimeButton({ + required this.item, + required this.profile, + required this.initialElapsedSeconds, + }); + + final MediaItem item; + final List profile; + final int initialElapsedSeconds; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return TextButton.icon( + icon: const Icon(Icons.push_pin_outlined), + label: Text(context.l10n.media_timeInDive_setAction), + onPressed: () async { + final choice = await showSetMediaTimeDialog( + context, + profile: profile, + initialElapsedSeconds: initialElapsedSeconds, + isPinned: item.manualElapsedSeconds != null, + settings: ref.read(settingsProvider), + ); + if (choice == null || !context.mounted) return; + await ref.read(mediaTimePinnerProvider).apply(item, choice); + }, + ); + } +} + class _OriginSection extends ConsumerWidget { const _OriginSection({ required this.item, diff --git a/lib/features/media/presentation/widgets/set_media_time_dialog.dart b/lib/features/media/presentation/widgets/set_media_time_dialog.dart new file mode 100644 index 0000000000..7d5a3bdd1f --- /dev/null +++ b/lib/features/media/presentation/widgets/set_media_time_dialog.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/media/data/services/enrichment_service.dart'; +import 'package:submersion/features/media/domain/entities/media_dive_window.dart'; +import 'package:submersion/features/media/presentation/helpers/elapsed_time_format.dart'; +import 'package:submersion/features/media/presentation/helpers/media_time_choice.dart'; +import 'package:submersion/features/media/presentation/widgets/mini_dive_profile_overlay.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +export 'package:submersion/features/media/presentation/helpers/media_time_choice.dart'; + +/// Opens the Set-time dialog and resolves with the diver's [MediaTimeChoice], +/// or null when they cancel (issue #1090). +/// +/// [initialElapsedSeconds] seeds the field: the current pin, or the automatic +/// position when it is inside the dive, or 0. [isPinned] offers the Reset +/// action, which only means something when a pin exists. +Future showSetMediaTimeDialog( + BuildContext context, { + required List profile, + required int initialElapsedSeconds, + required bool isPinned, + required AppSettings settings, +}) { + return showDialog( + context: context, + builder: (_) => SetMediaTimeDialog( + profile: profile, + initialElapsedSeconds: initialElapsedSeconds, + isPinned: isPinned, + settings: settings, + ), + ); +} + +/// A minutes:seconds field and a slider over the dive's length, previewed +/// live on the mini dive profile so the diver can see the depth they are +/// pinning the shot to. +class SetMediaTimeDialog extends StatefulWidget { + const SetMediaTimeDialog({ + super.key, + required this.profile, + required this.initialElapsedSeconds, + required this.isPinned, + required this.settings, + }); + + final List profile; + final int initialElapsedSeconds; + final bool isPinned; + final AppSettings settings; + + @override + State createState() => _SetMediaTimeDialogState(); +} + +class _SetMediaTimeDialogState extends State { + static const _enrichment = EnrichmentService(); + + late final int _maxSeconds = MediaDiveWindow.profileLengthSeconds( + widget.profile, + ); + late int _seconds = widget.initialElapsedSeconds.clamp(0, _maxSeconds); + late final TextEditingController _controller = TextEditingController( + text: formatElapsedMmSs(_seconds), + ); + bool _invalid = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool _inRange(int seconds) => seconds >= 0 && seconds <= _maxSeconds; + + void _onFieldChanged(String text) { + final parsed = parseElapsedMmSs(text); + // The slider and preview follow only a usable value; the field keeps + // whatever is being typed so a half-entered time is not fought. + if (parsed == null || !_inRange(parsed)) return; + setState(() { + _seconds = parsed; + _invalid = false; + }); + } + + void _onSliderChanged(double value) { + final seconds = value.round(); + setState(() { + _seconds = seconds; + _invalid = false; + _controller.text = formatElapsedMmSs(seconds); + }); + } + + void _save() { + final parsed = parseElapsedMmSs(_controller.text); + if (parsed == null || !_inRange(parsed)) { + setState(() => _invalid = true); + return; + } + Navigator.of(context).pop(MediaTimePinned(parsed)); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final max = formatElapsedMmSs(_maxSeconds); + final preview = _enrichment.calculateEnrichmentAtElapsed( + profile: widget.profile, + elapsedSeconds: _seconds, + ); + + return AlertDialog( + title: Text(l10n.media_timeInDive_label), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _controller, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9:]')), + ], + decoration: InputDecoration( + labelText: l10n.media_timeInDive_fieldLabel, + hintText: l10n.media_timeInDive_fieldHint, + helperText: l10n.media_timeInDive_range(max), + errorText: _invalid ? l10n.media_timeInDive_invalid(max) : null, + ), + onChanged: _onFieldChanged, + onSubmitted: (_) => _save(), + ), + if (_maxSeconds > 0) + Slider( + value: _seconds.toDouble(), + min: 0, + max: _maxSeconds.toDouble(), + onChanged: _onSliderChanged, + ), + const SizedBox(height: 8), + Center( + child: MiniDiveProfileOverlay( + profile: widget.profile, + photoElapsedSeconds: _seconds, + photoDepthMeters: preview.depthMeters, + settings: widget.settings, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.media_timeInDive_cancel), + ), + if (widget.isPinned) + TextButton( + onPressed: () => Navigator.of(context).pop(const MediaTimeReset()), + child: Text(l10n.media_timeInDive_reset), + ), + FilledButton(onPressed: _save, child: Text(l10n.media_timeInDive_save)), + ], + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..0c0e2655a0 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "غير موجود على هذا الجهاز", "media_info_statusUnchecked": "لم يتم التحقق بعد", "media_info_lastChecked": "آخر فحص {date}", + "media_timeInDive_label": "الوقت في الغوصة", + "media_timeInDive_unknown": "الوقت في الغوصة غير معروف", + "media_timeInDive_setAction": "تعيين الوقت في الغوصة", + "media_timeInDive_manual": "{time} (تم تعيينه يدويًا)", + "media_timeInDive_fieldLabel": "الوقت من بداية الغوصة", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "بين 0:00 و {max}", + "media_timeInDive_invalid": "أدخل وقتًا بين 0:00 و {max}", + "media_timeInDive_save": "حفظ", + "media_timeInDive_cancel": "إلغاء", + "media_timeInDive_reset": "إعادة التعيين إلى التلقائي", "media_info_backupSection": "النسخ الاحتياطي", "media_info_store": "التخزين السحابي", "media_info_storeNotConnected": "لا يوجد تخزين سحابي متصل", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..c239f10fe3 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "Auf diesem Gerät nicht vorhanden", "media_info_statusUnchecked": "Noch nicht geprüft", "media_info_lastChecked": "Zuletzt geprüft {date}", + "media_timeInDive_label": "Zeitpunkt im Tauchgang", + "media_timeInDive_unknown": "Zeitpunkt im Tauchgang unbekannt", + "media_timeInDive_setAction": "Zeitpunkt im Tauchgang festlegen", + "media_timeInDive_manual": "{time} (manuell festgelegt)", + "media_timeInDive_fieldLabel": "Zeit seit Tauchgangsbeginn", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "Zwischen 0:00 und {max}", + "media_timeInDive_invalid": "Gib eine Zeit zwischen 0:00 und {max} ein", + "media_timeInDive_save": "Speichern", + "media_timeInDive_cancel": "Abbrechen", + "media_timeInDive_reset": "Auf automatisch zurücksetzen", "media_info_backupSection": "Sicherung", "media_info_store": "Cloud-Speicher", "media_info_storeNotConnected": "Kein Cloud-Speicher verbunden", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..9a5bb80b6d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -18519,6 +18519,49 @@ } } }, + "media_timeInDive_label": "Time in dive", + "@media_timeInDive_label": {"description": "Media info row label and Set-time dialog title: the moment in the dive a media item was taken."}, + "media_timeInDive_unknown": "Time in dive unknown", + "@media_timeInDive_unknown": {"description": "Viewer chip shown when a media item's capture time falls outside its dive; tapping it opens the Set-time dialog."}, + "media_timeInDive_setAction": "Set time in dive", + "@media_timeInDive_setAction": {"description": "Button and tooltip that opens the dialog to pin a media item to a moment in the dive."}, + "media_timeInDive_manual": "{time} (set manually)", + "@media_timeInDive_manual": { + "description": "Media info row value for a position the diver set themselves; {time} is a minutes:seconds offset.", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "media_timeInDive_fieldLabel": "Time from dive start", + "@media_timeInDive_fieldLabel": {"description": "Set-time dialog: label of the minutes:seconds field."}, + "media_timeInDive_fieldHint": "mm:ss", + "@media_timeInDive_fieldHint": {"description": "Set-time dialog: placeholder showing the minutes:seconds format."}, + "media_timeInDive_range": "Between 0:00 and {max}", + "@media_timeInDive_range": { + "description": "Set-time dialog: helper text under the field; {max} is the dive length as minutes:seconds.", + "placeholders": { + "max": { + "type": "String" + } + } + }, + "media_timeInDive_invalid": "Enter a time between 0:00 and {max}", + "@media_timeInDive_invalid": { + "description": "Set-time dialog: error shown for malformed or out-of-range input; {max} is the dive length as minutes:seconds.", + "placeholders": { + "max": { + "type": "String" + } + } + }, + "media_timeInDive_save": "Save", + "@media_timeInDive_save": {"description": "Set-time dialog: confirm button."}, + "media_timeInDive_cancel": "Cancel", + "@media_timeInDive_cancel": {"description": "Set-time dialog: dismiss button."}, + "media_timeInDive_reset": "Reset to automatic", + "@media_timeInDive_reset": {"description": "Set-time dialog: button that removes the diver's pin so the position derives from the capture time again."}, "media_info_backupSection": "Backup", "media_info_store": "Cloud store", "media_info_storeNotConnected": "No cloud store connected", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..666aca4137 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "No esta en este dispositivo", "media_info_statusUnchecked": "Aun sin comprobar", "media_info_lastChecked": "Última comprobación {date}", + "media_timeInDive_label": "Momento de la inmersión", + "media_timeInDive_unknown": "Momento de la inmersión desconocido", + "media_timeInDive_setAction": "Definir momento de la inmersión", + "media_timeInDive_manual": "{time} (definido manualmente)", + "media_timeInDive_fieldLabel": "Tiempo desde el inicio de la inmersión", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "Entre 0:00 y {max}", + "media_timeInDive_invalid": "Introduce un tiempo entre 0:00 y {max}", + "media_timeInDive_save": "Guardar", + "media_timeInDive_cancel": "Cancelar", + "media_timeInDive_reset": "Restablecer a automático", "media_info_backupSection": "Copia de seguridad", "media_info_store": "Almacén en la nube", "media_info_storeNotConnected": "Sin almacén en la nube conectado", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..42dfbf7213 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "Absente de cet appareil", "media_info_statusUnchecked": "Pas encore vérifiée", "media_info_lastChecked": "Dernière vérification {date}", + "media_timeInDive_label": "Moment dans la plongée", + "media_timeInDive_unknown": "Moment dans la plongée inconnu", + "media_timeInDive_setAction": "Définir le moment dans la plongée", + "media_timeInDive_manual": "{time} (défini manuellement)", + "media_timeInDive_fieldLabel": "Temps depuis le début de la plongée", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "Entre 0:00 et {max}", + "media_timeInDive_invalid": "Saisissez un temps entre 0:00 et {max}", + "media_timeInDive_save": "Enregistrer", + "media_timeInDive_cancel": "Annuler", + "media_timeInDive_reset": "Rétablir le mode automatique", "media_info_backupSection": "Sauvegarde", "media_info_store": "Stockage cloud", "media_info_storeNotConnected": "Aucun stockage cloud connecté", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..c470c74ee7 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "חסר במכשיר זה", "media_info_statusUnchecked": "טרם נבדק", "media_info_lastChecked": "נבדק לאחרונה {date}", + "media_timeInDive_label": "זמן בצלילה", + "media_timeInDive_unknown": "זמן בצלילה לא ידוע", + "media_timeInDive_setAction": "הגדרת זמן בצלילה", + "media_timeInDive_manual": "{time} (הוגדר ידנית)", + "media_timeInDive_fieldLabel": "זמן מתחילת הצלילה", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "בין 0:00 ל-{max}", + "media_timeInDive_invalid": "יש להזין זמן בין 0:00 ל-{max}", + "media_timeInDive_save": "שמור", + "media_timeInDive_cancel": "ביטול", + "media_timeInDive_reset": "איפוס לאוטומטי", "media_info_backupSection": "גיבוי", "media_info_store": "אחסון בענן", "media_info_storeNotConnected": "לא מחובר אחסון בענן", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..6e137b5dcc 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "Hiányzik erről az eszközről", "media_info_statusUnchecked": "Még nincs ellenőrizve", "media_info_lastChecked": "Utoljára ellenőrizve {date}", + "media_timeInDive_label": "Időpont a merülésben", + "media_timeInDive_unknown": "Időpont a merülésben ismeretlen", + "media_timeInDive_setAction": "Időpont beállítása a merülésben", + "media_timeInDive_manual": "{time} (kézzel beállítva)", + "media_timeInDive_fieldLabel": "Idő a merülés kezdetétől", + "media_timeInDive_fieldHint": "pp:mm", + "media_timeInDive_range": "0:00 és {max} között", + "media_timeInDive_invalid": "Adj meg egy időt 0:00 és {max} között", + "media_timeInDive_save": "Mentes", + "media_timeInDive_cancel": "Megse", + "media_timeInDive_reset": "Visszaállítás automatikusra", "media_info_backupSection": "Biztonsági mentés", "media_info_store": "Felhő tárhely", "media_info_storeNotConnected": "Nincs csatlakoztatott felhő tárhely", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..406c78dd49 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "Assente da questo dispositivo", "media_info_statusUnchecked": "Non ancora verificata", "media_info_lastChecked": "Ultimo controllo {date}", + "media_timeInDive_label": "Momento dell'immersione", + "media_timeInDive_unknown": "Momento dell'immersione sconosciuto", + "media_timeInDive_setAction": "Imposta il momento dell'immersione", + "media_timeInDive_manual": "{time} (impostato manualmente)", + "media_timeInDive_fieldLabel": "Tempo dall'inizio dell'immersione", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "Tra 0:00 e {max}", + "media_timeInDive_invalid": "Inserisci un tempo tra 0:00 e {max}", + "media_timeInDive_save": "Salva", + "media_timeInDive_cancel": "Annulla", + "media_timeInDive_reset": "Ripristina automatico", "media_info_backupSection": "Backup", "media_info_store": "Archivio cloud", "media_info_storeNotConnected": "Nessun archivio cloud collegato", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..2b35779b1b 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -54190,6 +54190,72 @@ abstract class AppLocalizations { /// **'Last checked {date}'** String media_info_lastChecked(String date); + /// Media info row label and Set-time dialog title: the moment in the dive a media item was taken. + /// + /// In en, this message translates to: + /// **'Time in dive'** + String get media_timeInDive_label; + + /// Viewer chip shown when a media item's capture time falls outside its dive; tapping it opens the Set-time dialog. + /// + /// In en, this message translates to: + /// **'Time in dive unknown'** + String get media_timeInDive_unknown; + + /// Button and tooltip that opens the dialog to pin a media item to a moment in the dive. + /// + /// In en, this message translates to: + /// **'Set time in dive'** + String get media_timeInDive_setAction; + + /// Media info row value for a position the diver set themselves; {time} is a minutes:seconds offset. + /// + /// In en, this message translates to: + /// **'{time} (set manually)'** + String media_timeInDive_manual(String time); + + /// Set-time dialog: label of the minutes:seconds field. + /// + /// In en, this message translates to: + /// **'Time from dive start'** + String get media_timeInDive_fieldLabel; + + /// Set-time dialog: placeholder showing the minutes:seconds format. + /// + /// In en, this message translates to: + /// **'mm:ss'** + String get media_timeInDive_fieldHint; + + /// Set-time dialog: helper text under the field; {max} is the dive length as minutes:seconds. + /// + /// In en, this message translates to: + /// **'Between 0:00 and {max}'** + String media_timeInDive_range(String max); + + /// Set-time dialog: error shown for malformed or out-of-range input; {max} is the dive length as minutes:seconds. + /// + /// In en, this message translates to: + /// **'Enter a time between 0:00 and {max}'** + String media_timeInDive_invalid(String max); + + /// Set-time dialog: confirm button. + /// + /// In en, this message translates to: + /// **'Save'** + String get media_timeInDive_save; + + /// Set-time dialog: dismiss button. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get media_timeInDive_cancel; + + /// Set-time dialog: button that removes the diver's pin so the position derives from the capture time again. + /// + /// In en, this message translates to: + /// **'Reset to automatic'** + String get media_timeInDive_reset; + /// No description provided for @media_info_backupSection. /// /// 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..ac5fa38158 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -32491,6 +32491,45 @@ class AppLocalizationsAr extends AppLocalizations { return 'آخر فحص $date'; } + @override + String get media_timeInDive_label => 'الوقت في الغوصة'; + + @override + String get media_timeInDive_unknown => 'الوقت في الغوصة غير معروف'; + + @override + String get media_timeInDive_setAction => 'تعيين الوقت في الغوصة'; + + @override + String media_timeInDive_manual(String time) { + return '$time (تم تعيينه يدويًا)'; + } + + @override + String get media_timeInDive_fieldLabel => 'الوقت من بداية الغوصة'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'بين 0:00 و $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'أدخل وقتًا بين 0:00 و $max'; + } + + @override + String get media_timeInDive_save => 'حفظ'; + + @override + String get media_timeInDive_cancel => 'إلغاء'; + + @override + String get media_timeInDive_reset => 'إعادة التعيين إلى التلقائي'; + @override String get media_info_backupSection => 'النسخ الاحتياطي'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..ff3789a78f 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -32733,6 +32733,45 @@ class AppLocalizationsDe extends AppLocalizations { return 'Zuletzt geprüft $date'; } + @override + String get media_timeInDive_label => 'Zeitpunkt im Tauchgang'; + + @override + String get media_timeInDive_unknown => 'Zeitpunkt im Tauchgang unbekannt'; + + @override + String get media_timeInDive_setAction => 'Zeitpunkt im Tauchgang festlegen'; + + @override + String media_timeInDive_manual(String time) { + return '$time (manuell festgelegt)'; + } + + @override + String get media_timeInDive_fieldLabel => 'Zeit seit Tauchgangsbeginn'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'Zwischen 0:00 und $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Gib eine Zeit zwischen 0:00 und $max ein'; + } + + @override + String get media_timeInDive_save => 'Speichern'; + + @override + String get media_timeInDive_cancel => 'Abbrechen'; + + @override + String get media_timeInDive_reset => 'Auf automatisch zurücksetzen'; + @override String get media_info_backupSection => 'Sicherung'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..f53bae1516 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -32285,6 +32285,45 @@ class AppLocalizationsEn extends AppLocalizations { return 'Last checked $date'; } + @override + String get media_timeInDive_label => 'Time in dive'; + + @override + String get media_timeInDive_unknown => 'Time in dive unknown'; + + @override + String get media_timeInDive_setAction => 'Set time in dive'; + + @override + String media_timeInDive_manual(String time) { + return '$time (set manually)'; + } + + @override + String get media_timeInDive_fieldLabel => 'Time from dive start'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'Between 0:00 and $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Enter a time between 0:00 and $max'; + } + + @override + String get media_timeInDive_save => 'Save'; + + @override + String get media_timeInDive_cancel => 'Cancel'; + + @override + String get media_timeInDive_reset => 'Reset to automatic'; + @override String get media_info_backupSection => 'Backup'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..32d67360b9 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -32838,6 +32838,46 @@ class AppLocalizationsEs extends AppLocalizations { return 'Última comprobación $date'; } + @override + String get media_timeInDive_label => 'Momento de la inmersión'; + + @override + String get media_timeInDive_unknown => 'Momento de la inmersión desconocido'; + + @override + String get media_timeInDive_setAction => 'Definir momento de la inmersión'; + + @override + String media_timeInDive_manual(String time) { + return '$time (definido manualmente)'; + } + + @override + String get media_timeInDive_fieldLabel => + 'Tiempo desde el inicio de la inmersión'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'Entre 0:00 y $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Introduce un tiempo entre 0:00 y $max'; + } + + @override + String get media_timeInDive_save => 'Guardar'; + + @override + String get media_timeInDive_cancel => 'Cancelar'; + + @override + String get media_timeInDive_reset => 'Restablecer a automático'; + @override String get media_info_backupSection => 'Copia de seguridad'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..8dc9c2074c 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -32884,6 +32884,46 @@ class AppLocalizationsFr extends AppLocalizations { return 'Dernière vérification $date'; } + @override + String get media_timeInDive_label => 'Moment dans la plongée'; + + @override + String get media_timeInDive_unknown => 'Moment dans la plongée inconnu'; + + @override + String get media_timeInDive_setAction => 'Définir le moment dans la plongée'; + + @override + String media_timeInDive_manual(String time) { + return '$time (défini manuellement)'; + } + + @override + String get media_timeInDive_fieldLabel => + 'Temps depuis le début de la plongée'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'Entre 0:00 et $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Saisissez un temps entre 0:00 et $max'; + } + + @override + String get media_timeInDive_save => 'Enregistrer'; + + @override + String get media_timeInDive_cancel => 'Annuler'; + + @override + String get media_timeInDive_reset => 'Rétablir le mode automatique'; + @override String get media_info_backupSection => 'Sauvegarde'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..684e9b3cf0 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -32151,6 +32151,45 @@ class AppLocalizationsHe extends AppLocalizations { return 'נבדק לאחרונה $date'; } + @override + String get media_timeInDive_label => 'זמן בצלילה'; + + @override + String get media_timeInDive_unknown => 'זמן בצלילה לא ידוע'; + + @override + String get media_timeInDive_setAction => 'הגדרת זמן בצלילה'; + + @override + String media_timeInDive_manual(String time) { + return '$time (הוגדר ידנית)'; + } + + @override + String get media_timeInDive_fieldLabel => 'זמן מתחילת הצלילה'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'בין 0:00 ל-$max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'יש להזין זמן בין 0:00 ל-$max'; + } + + @override + String get media_timeInDive_save => 'שמור'; + + @override + String get media_timeInDive_cancel => 'ביטול'; + + @override + String get media_timeInDive_reset => 'איפוס לאוטומטי'; + @override String get media_info_backupSection => 'גיבוי'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..0fb937dd70 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -32679,6 +32679,45 @@ class AppLocalizationsHu extends AppLocalizations { return 'Utoljára ellenőrizve $date'; } + @override + String get media_timeInDive_label => 'Időpont a merülésben'; + + @override + String get media_timeInDive_unknown => 'Időpont a merülésben ismeretlen'; + + @override + String get media_timeInDive_setAction => 'Időpont beállítása a merülésben'; + + @override + String media_timeInDive_manual(String time) { + return '$time (kézzel beállítva)'; + } + + @override + String get media_timeInDive_fieldLabel => 'Idő a merülés kezdetétől'; + + @override + String get media_timeInDive_fieldHint => 'pp:mm'; + + @override + String media_timeInDive_range(String max) { + return '0:00 és $max között'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Adj meg egy időt 0:00 és $max között'; + } + + @override + String get media_timeInDive_save => 'Mentes'; + + @override + String get media_timeInDive_cancel => 'Megse'; + + @override + String get media_timeInDive_reset => 'Visszaállítás automatikusra'; + @override String get media_info_backupSection => 'Biztonsági mentés'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..c9d72a859c 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -32795,6 +32795,47 @@ class AppLocalizationsIt extends AppLocalizations { return 'Ultimo controllo $date'; } + @override + String get media_timeInDive_label => 'Momento dell\'immersione'; + + @override + String get media_timeInDive_unknown => 'Momento dell\'immersione sconosciuto'; + + @override + String get media_timeInDive_setAction => + 'Imposta il momento dell\'immersione'; + + @override + String media_timeInDive_manual(String time) { + return '$time (impostato manualmente)'; + } + + @override + String get media_timeInDive_fieldLabel => + 'Tempo dall\'inizio dell\'immersione'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'Tra 0:00 e $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Inserisci un tempo tra 0:00 e $max'; + } + + @override + String get media_timeInDive_save => 'Salva'; + + @override + String get media_timeInDive_cancel => 'Annulla'; + + @override + String get media_timeInDive_reset => 'Ripristina automatico'; + @override String get media_info_backupSection => 'Backup'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..6b2392ddb2 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -32574,6 +32574,45 @@ class AppLocalizationsNl extends AppLocalizations { return 'Laatst gecontroleerd $date'; } + @override + String get media_timeInDive_label => 'Tijdstip in de duik'; + + @override + String get media_timeInDive_unknown => 'Tijdstip in de duik onbekend'; + + @override + String get media_timeInDive_setAction => 'Tijdstip in de duik instellen'; + + @override + String media_timeInDive_manual(String time) { + return '$time (handmatig ingesteld)'; + } + + @override + String get media_timeInDive_fieldLabel => 'Tijd sinds het begin van de duik'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'Tussen 0:00 en $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Voer een tijd in tussen 0:00 en $max'; + } + + @override + String get media_timeInDive_save => 'Opslaan'; + + @override + String get media_timeInDive_cancel => 'Annuleren'; + + @override + String get media_timeInDive_reset => 'Terugzetten naar automatisch'; + @override String get media_info_backupSection => 'Back-up'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..a8c775a533 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -32810,6 +32810,45 @@ class AppLocalizationsPt extends AppLocalizations { return 'Última verificação $date'; } + @override + String get media_timeInDive_label => 'Momento do mergulho'; + + @override + String get media_timeInDive_unknown => 'Momento do mergulho desconhecido'; + + @override + String get media_timeInDive_setAction => 'Definir momento do mergulho'; + + @override + String media_timeInDive_manual(String time) { + return '$time (definido manualmente)'; + } + + @override + String get media_timeInDive_fieldLabel => 'Tempo desde o início do mergulho'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return 'Entre 0:00 e $max'; + } + + @override + String media_timeInDive_invalid(String max) { + return 'Insira um tempo entre 0:00 e $max'; + } + + @override + String get media_timeInDive_save => 'Salvar'; + + @override + String get media_timeInDive_cancel => 'Cancelar'; + + @override + String get media_timeInDive_reset => 'Redefinir para automático'; + @override String get media_info_backupSection => 'Backup'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..c9a26708f7 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -30916,6 +30916,45 @@ class AppLocalizationsZh extends AppLocalizations { return '上次检查 $date'; } + @override + String get media_timeInDive_label => '潜水中的时间点'; + + @override + String get media_timeInDive_unknown => '潜水中的时间点未知'; + + @override + String get media_timeInDive_setAction => '设置潜水中的时间点'; + + @override + String media_timeInDive_manual(String time) { + return '$time(手动设置)'; + } + + @override + String get media_timeInDive_fieldLabel => '距潜水开始的时间'; + + @override + String get media_timeInDive_fieldHint => 'mm:ss'; + + @override + String media_timeInDive_range(String max) { + return '介于 0:00 和 $max 之间'; + } + + @override + String media_timeInDive_invalid(String max) { + return '请输入介于 0:00 和 $max 之间的时间'; + } + + @override + String get media_timeInDive_save => '保存'; + + @override + String get media_timeInDive_cancel => '取消'; + + @override + String get media_timeInDive_reset => '重置为自动'; + @override String get media_info_backupSection => '备份'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..8c51a1a0c9 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "Ontbreekt op dit apparaat", "media_info_statusUnchecked": "Nog niet gecontroleerd", "media_info_lastChecked": "Laatst gecontroleerd {date}", + "media_timeInDive_label": "Tijdstip in de duik", + "media_timeInDive_unknown": "Tijdstip in de duik onbekend", + "media_timeInDive_setAction": "Tijdstip in de duik instellen", + "media_timeInDive_manual": "{time} (handmatig ingesteld)", + "media_timeInDive_fieldLabel": "Tijd sinds het begin van de duik", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "Tussen 0:00 en {max}", + "media_timeInDive_invalid": "Voer een tijd in tussen 0:00 en {max}", + "media_timeInDive_save": "Opslaan", + "media_timeInDive_cancel": "Annuleren", + "media_timeInDive_reset": "Terugzetten naar automatisch", "media_info_backupSection": "Back-up", "media_info_store": "Cloudopslag", "media_info_storeNotConnected": "Geen cloudopslag verbonden", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..4ea9ef8810 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "Ausente neste dispositivo", "media_info_statusUnchecked": "Ainda não verificada", "media_info_lastChecked": "Última verificação {date}", + "media_timeInDive_label": "Momento do mergulho", + "media_timeInDive_unknown": "Momento do mergulho desconhecido", + "media_timeInDive_setAction": "Definir momento do mergulho", + "media_timeInDive_manual": "{time} (definido manualmente)", + "media_timeInDive_fieldLabel": "Tempo desde o início do mergulho", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "Entre 0:00 e {max}", + "media_timeInDive_invalid": "Insira um tempo entre 0:00 e {max}", + "media_timeInDive_save": "Salvar", + "media_timeInDive_cancel": "Cancelar", + "media_timeInDive_reset": "Redefinir para automático", "media_info_backupSection": "Backup", "media_info_store": "Armazenamento na nuvem", "media_info_storeNotConnected": "Nenhum armazenamento na nuvem conectado", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..887e5dd1a7 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -9631,6 +9631,17 @@ "media_info_statusMissing": "此设备上缺失", "media_info_statusUnchecked": "尚未检查", "media_info_lastChecked": "上次检查 {date}", + "media_timeInDive_label": "潜水中的时间点", + "media_timeInDive_unknown": "潜水中的时间点未知", + "media_timeInDive_setAction": "设置潜水中的时间点", + "media_timeInDive_manual": "{time}(手动设置)", + "media_timeInDive_fieldLabel": "距潜水开始的时间", + "media_timeInDive_fieldHint": "mm:ss", + "media_timeInDive_range": "介于 0:00 和 {max} 之间", + "media_timeInDive_invalid": "请输入介于 0:00 和 {max} 之间的时间", + "media_timeInDive_save": "保存", + "media_timeInDive_cancel": "取消", + "media_timeInDive_reset": "重置为自动", "media_info_backupSection": "备份", "media_info_store": "云存储", "media_info_storeNotConnected": "未连接云存储", diff --git a/test/core/database/migration_v162_media_manual_elapsed_test.dart b/test/core/database/migration_v162_media_manual_elapsed_test.dart new file mode 100644 index 0000000000..31756bcb87 --- /dev/null +++ b/test/core/database/migration_v162_media_manual_elapsed_test.dart @@ -0,0 +1,92 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +/// v162 adds `media.manual_elapsed_seconds`: the moment in the dive the diver +/// pinned a media item to when its capture time is wrong or missing +/// (issue #1090). Nullable with no default, because null means "position it +/// from the capture time" and a pre-v162 writer's payload omits the key. +NativeDatabase _dbAt161() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 161'); + rawDb.execute(''' + CREATE TABLE media ( + id TEXT NOT NULL PRIMARY KEY, + file_path TEXT NOT NULL, + file_type TEXT NOT NULL DEFAULT 'photo', + retain_in_library INTEGER NOT NULL DEFAULT 0 + ) + '''); + rawDb.execute("INSERT INTO media (id, file_path) VALUES ('m1', '')"); + }, + ); +} + +void main() { + test('v162 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(162)); + expect(AppDatabase.migrationVersions, contains(162)); + }); + + test('a fresh database has media.manual_elapsed_seconds', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('media')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('manual_elapsed_seconds')); + }); + + test('the column is nullable and carries no default', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('media')").get(); + final column = cols.firstWhere( + (c) => c.read('name') == 'manual_elapsed_seconds', + ); + // A non-null default would claim the diver pinned every existing item + // to the start of its dive. + expect(column.read('notnull'), 0); + expect(column.read('dflt_value'), isNull); + }); + + test('a database at v161 gains the column and keeps its rows', () async { + final db = AppDatabase(_dbAt161()); + addTearDown(db.close); + + final row = await db + .customSelect( + "SELECT manual_elapsed_seconds FROM media WHERE id = 'm1'", + ) + .getSingle(); + expect(row.read('manual_elapsed_seconds'), isNull); + }); + + test('a database stranded at a parallel-branch v162 gains the column via ' + 'beforeOpen', () async { + // Stamped AT 162 but without the column: the onUpgrade block never + // runs, so only the beforeOpen backstop can add it. + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 162'); + rawDb.execute(''' + CREATE TABLE media ( + id TEXT NOT NULL PRIMARY KEY, + file_path TEXT NOT NULL, + file_type TEXT NOT NULL DEFAULT 'photo', + retain_in_library INTEGER NOT NULL DEFAULT 0 + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('media')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('manual_elapsed_seconds')); + }); +} diff --git a/test/features/dive_3d/domain/entities/dive_3d_scene_data_test.dart b/test/features/dive_3d/domain/entities/dive_3d_scene_data_test.dart index b77a5f0fff..72128c6053 100644 --- a/test/features/dive_3d/domain/entities/dive_3d_scene_data_test.dart +++ b/test/features/dive_3d/domain/entities/dive_3d_scene_data_test.dart @@ -2,10 +2,36 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/features/dive_3d/domain/entities/dive_3d_scene_data.dart'; import 'package:submersion/features/dive_3d/domain/metric_palette.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; DiveProfilePoint point(int t, double d, {double? temp, double? ppO2}) => DiveProfilePoint(timestamp: t, depth: d, temperature: temp, ppO2: ppO2); +MediaItem photo( + String id, { + int? elapsedSeconds, + MatchConfidence confidence = MatchConfidence.exact, +}) { + final now = DateTime.utc(2026, 1, 1); + return MediaItem( + id: id, + diveId: 'd1', + mediaType: MediaType.photo, + takenAt: now, + createdAt: now, + updatedAt: now, + enrichment: MediaEnrichment( + id: 'e-$id', + mediaId: id, + diveId: 'd1', + elapsedSeconds: elapsedSeconds, + depthMeters: 5, + matchConfidence: confidence, + createdAt: now, + ), + ); +} + void main() { group('Dive3dSceneData.fromDomain', () { test('extracts parallel series from profile points', () { @@ -37,6 +63,34 @@ void main() { expect(data.hasProfile, isFalse); }); + // Issue #1090: the 3D scene reads the same enrichment the chart does, + // so it applies the same dive-window tolerance instead of drawing a + // wrong-dated photo at the surface. + test('keeps only photos positioned inside the dive window', () { + final data = Dive3dSceneData.fromDomain( + diveId: 'd1', + points: [point(0, 0), point(3600, 18)], + tankPressures: const {}, + gasSwitches: const [], + events: const [], + photos: [ + photo('inside', elapsedSeconds: 600), + photo('unpositioned'), + photo( + 'days-late', + elapsedSeconds: 1879 * 60, + confidence: MatchConfidence.estimated, + ), + photo( + 'pinned-late', + elapsedSeconds: 1879 * 60, + confidence: MatchConfidence.manual, + ), + ], + ); + expect(data.photos.map((m) => m.id), ['inside', 'pinned-late']); + }); + test('availableMetrics reflects present data only', () { final data = Dive3dSceneData.fromDomain( diveId: 'd1', diff --git a/test/features/dive_log/presentation/widgets/photo_marker_layout_test.dart b/test/features/dive_log/presentation/widgets/photo_marker_layout_test.dart index a8186a08fc..4f5af42f86 100644 --- a/test/features/dive_log/presentation/widgets/photo_marker_layout_test.dart +++ b/test/features/dive_log/presentation/widgets/photo_marker_layout_test.dart @@ -87,6 +87,53 @@ void main() { expect(markers[0].elapsedSeconds, 0); expect(markers[1].elapsedSeconds, 3600); }); + + // Issue #1090: a capture time days outside the dive used to clamp to the + // start or end of the profile, so a wrong EXIF date drew a confident + // marker at the exit. Beyond the matcher's own buffers the position is + // not knowledge, and the chart must not invent one. + test('drops automatic positions beyond the dive window tolerance', () { + final markers = photoMarkersFromMedia([ + _media( + id: 'years-early', + enrichment: _enrichment( + elapsedSeconds: -5554653 * 60, + confidence: MatchConfidence.estimated, + ), + ), + _media( + id: 'days-late', + enrichment: _enrichment( + elapsedSeconds: 1879 * 60, + confidence: MatchConfidence.estimated, + ), + ), + _media( + id: 'just-after', + enrichment: _enrichment( + elapsedSeconds: 3600 + 300, + confidence: MatchConfidence.estimated, + ), + ), + ], maxProfileSeconds: 3600); + expect(markers.map((m) => m.item.id), ['just-after']); + expect(markers.single.elapsedSeconds, 3600); + }); + + test('keeps a manual position regardless of the tolerance', () { + final markers = photoMarkersFromMedia([ + _media( + id: 'pinned', + enrichment: _enrichment( + elapsedSeconds: 1879 * 60, + confidence: MatchConfidence.manual, + ), + ), + ], maxProfileSeconds: 3600); + expect(markers, hasLength(1)); + // A manual offset past a since-shortened profile still clamps to it. + expect(markers.single.elapsedSeconds, 3600); + }); }); group('clusterPhotoMarkers', () { diff --git a/test/features/media/data/repositories/media_repository_manual_elapsed_test.dart b/test/features/media/data/repositories/media_repository_manual_elapsed_test.dart new file mode 100644 index 0000000000..d78bc23e3f --- /dev/null +++ b/test/features/media/data/repositories/media_repository_manual_elapsed_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as domain; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1090: the diver's manual position for a media item lives on the +/// media row so it syncs with the row and outlives every enrichment +/// recompute. +void main() { + late AppDatabase db; + late MediaRepository repository; + late String diveId; + + setUp(() async { + db = await setUpTestDatabase(); + repository = MediaRepository(); + final dive = await DiveRepository().createDive( + domain.Dive( + id: '', + diveNumber: 1, + dateTime: DateTime.utc(2026, 1, 1, 10), + ), + ); + diveId = dive.id; + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + MediaItem item({int? manualElapsedSeconds}) { + final now = DateTime.utc(2026, 1, 1, 10, 5); + return MediaItem( + id: '', + diveId: diveId, + filePath: '/photos/a.jpg', + mediaType: MediaType.photo, + takenAt: now, + manualElapsedSeconds: manualElapsedSeconds, + createdAt: now, + updatedAt: now, + ); + } + + Future> syncStatusesFor(String id) async { + final rows = await (db.select( + db.syncRecords, + )..where((t) => t.recordId.equals(id))).get(); + return rows.map((r) => r.syncStatus).toList(); + } + + test('createMedia persists the manual position', () async { + final created = await repository.createMedia( + item(manualElapsedSeconds: 720), + ); + final fetched = await repository.getMediaById(created.id); + expect(fetched!.manualElapsedSeconds, 720); + }); + + test('a row without a pin reads back as automatic', () async { + final created = await repository.createMedia(item()); + final fetched = await repository.getMediaById(created.id); + expect(fetched!.manualElapsedSeconds, isNull); + }); + + test('updateMedia persists a changed manual position', () async { + final created = await repository.createMedia(item()); + await repository.updateMedia(created.copyWith(manualElapsedSeconds: 90)); + final fetched = await repository.getMediaById(created.id); + expect(fetched!.manualElapsedSeconds, 90); + }); + + group('setManualElapsedSeconds', () { + test('pins the item and marks the row sync-pending', () async { + final created = await repository.createMedia(item()); + // createMedia already queued a pending record; clear it so the + // assertion below is about this write alone. + await (db.delete( + db.syncRecords, + )..where((t) => t.recordId.equals(created.id))).go(); + + await repository.setManualElapsedSeconds(created.id, 1500); + + final fetched = await repository.getMediaById(created.id); + expect(fetched!.manualElapsedSeconds, 1500); + expect(await syncStatusesFor(created.id), contains('pending')); + }); + + test('null clears the pin back to automatic', () async { + final created = await repository.createMedia( + item(manualElapsedSeconds: 1500), + ); + + await repository.setManualElapsedSeconds(created.id, null); + + final fetched = await repository.getMediaById(created.id); + expect(fetched!.manualElapsedSeconds, isNull); + }); + + test('bumps updatedAt so the change wins on sync', () async { + final created = await repository.createMedia(item()); + final before = (await repository.getMediaById(created.id))!.updatedAt; + await Future.delayed(const Duration(milliseconds: 5)); + + await repository.setManualElapsedSeconds(created.id, 30); + + final after = (await repository.getMediaById(created.id))!.updatedAt; + expect(after.isAfter(before), isTrue); + }); + }); +} diff --git a/test/features/media/data/services/dive_media_enricher_test.dart b/test/features/media/data/services/dive_media_enricher_test.dart index 8e6e265043..ee99e29a08 100644 --- a/test/features/media/data/services/dive_media_enricher_test.dart +++ b/test/features/media/data/services/dive_media_enricher_test.dart @@ -31,6 +31,7 @@ MediaItem _media( ); void main() { + manualPositionTests(); test('enriches a linked item that has no enrichment yet', () async { final saved = []; final enricher = DiveMediaEnricher( @@ -261,3 +262,89 @@ void main() { ); }); } + +/// Issue #1090: a diver can pin a media item to a moment in the dive when +/// the file's capture time is wrong or missing. The pin lives on the media +/// row, so the enricher (the only writer of enrichment rows) must derive +/// the row from it instead of from the capture time, and must not revert it +/// on the next backfill pass. +void manualPositionTests() { + test( + 'positions a pinned item at its manual offset, not its capture time', + () async { + final saved = []; + final enricher = DiveMediaEnricher( + loadDive: (_) async => _diveWithProfile(), + loadMediaForDive: (_) async => [ + // Capture time is a decade off; the diver pinned it 42 min in. + _media( + 'm1', + takenAt: DateTime.utc(2016, 1, 6, 0, 3), + ).copyWith(manualElapsedSeconds: 2520), + ], + saveEnrichments: (rows) async => saved.addAll(rows), + ); + + expect(await enricher.enrichMissingForDive('d1'), 1); + expect(saved.single.elapsedSeconds, 2520); + expect(saved.single.depthMeters, 20.0); + expect(saved.single.matchConfidence, MatchConfidence.manual); + }, + ); + + test('leaves a stored manual row alone when it already matches', () async { + final saved = []; + final enricher = DiveMediaEnricher( + loadDive: (_) async => _diveWithProfile(), + loadMediaForDive: (_) async => [ + _media( + 'm1', + takenAt: DateTime.utc(2016, 1, 6, 0, 3), + enrichment: MediaEnrichment( + id: 'e1', + mediaId: 'm1', + diveId: 'd1', + elapsedSeconds: 2520, + depthMeters: 20, + temperatureCelsius: 26, + timestampOffsetSeconds: 0, + matchConfidence: MatchConfidence.manual, + createdAt: DateTime.utc(2025), + ), + ).copyWith(manualElapsedSeconds: 2520), + ], + saveEnrichments: (rows) async => saved.addAll(rows), + ); + + expect(await enricher.enrichMissingForDive('d1'), 0); + expect(saved, isEmpty); + }); + + test('clearing the pin recomputes from the capture time again', () async { + final saved = []; + final enricher = DiveMediaEnricher( + loadDive: (_) async => _diveWithProfile(), + loadMediaForDive: (_) async => [ + _media( + 'm1', + takenAt: DateTime.utc(2025, 12, 27, 12, 8), + enrichment: MediaEnrichment( + id: 'e1', + mediaId: 'm1', + diveId: 'd1', + elapsedSeconds: 600, + depthMeters: 20, + matchConfidence: MatchConfidence.manual, + createdAt: DateTime.utc(2025), + ), + ), + ], + saveEnrichments: (rows) async => saved.addAll(rows), + ); + + expect(await enricher.enrichMissingForDive('d1'), 1); + expect(saved.single.id, 'e1'); + expect(saved.single.elapsedSeconds, 2520); + expect(saved.single.matchConfidence, MatchConfidence.exact); + }); +} diff --git a/test/features/media/data/services/enrichment_service_test.dart b/test/features/media/data/services/enrichment_service_test.dart index d08d3b4a0a..2c299c4af2 100644 --- a/test/features/media/data/services/enrichment_service_test.dart +++ b/test/features/media/data/services/enrichment_service_test.dart @@ -4,6 +4,7 @@ import 'package:submersion/features/media/data/services/enrichment_service.dart' import 'package:submersion/features/media/domain/entities/media_item.dart'; void main() { + manualPositionTests(); late EnrichmentService service; late DateTime diveStartTime; @@ -412,3 +413,69 @@ void main() { }); }); } + +/// A manual position (issue #1090) starts from a dive offset the diver +/// chose, not from a capture time, so the wall-clock normalisation never +/// runs and the confidence is always [MatchConfidence.manual]: the profile +/// lookup is the same, but the diver's placement is not an estimate. +void manualPositionTests() { + const service = EnrichmentService(); + final profile = [ + const DiveProfilePoint(timestamp: 0, depth: 0.0), + const DiveProfilePoint(timestamp: 60, depth: 10.0, temperature: 22.0), + const DiveProfilePoint(timestamp: 120, depth: 18.0, temperature: 20.0), + ]; + + group('calculateEnrichmentAtElapsed', () { + test('snaps to a profile point within the exact threshold', () { + final result = service.calculateEnrichmentAtElapsed( + profile: profile, + elapsedSeconds: 65, + ); + expect(result.elapsedSeconds, 65); + expect(result.depthMeters, 10.0); + expect(result.temperatureCelsius, 22.0); + expect(result.timestampOffsetSeconds, 5); + expect(result.matchConfidence, MatchConfidence.manual); + }); + + test('interpolates between bracketing points', () { + final result = service.calculateEnrichmentAtElapsed( + profile: profile, + elapsedSeconds: 90, + ); + expect(result.depthMeters, 14.0); + expect(result.temperatureCelsius, 21.0); + expect(result.matchConfidence, MatchConfidence.manual); + }); + + test('uses the first point before the profile starts', () { + final result = service.calculateEnrichmentAtElapsed( + profile: profile, + elapsedSeconds: -30, + ); + expect(result.depthMeters, 0.0); + expect(result.elapsedSeconds, -30); + expect(result.matchConfidence, MatchConfidence.manual); + }); + + test('uses the last point after the profile ends', () { + final result = service.calculateEnrichmentAtElapsed( + profile: profile, + elapsedSeconds: 500, + ); + expect(result.depthMeters, 18.0); + expect(result.matchConfidence, MatchConfidence.manual); + }); + + test('reports noProfile for an empty profile', () { + final result = service.calculateEnrichmentAtElapsed( + profile: const [], + elapsedSeconds: 90, + ); + expect(result.matchConfidence, MatchConfidence.noProfile); + expect(result.depthMeters, isNull); + expect(result.elapsedSeconds, 90); + }); + }); +} diff --git a/test/features/media/domain/entities/media_dive_window_test.dart b/test/features/media/domain/entities/media_dive_window_test.dart new file mode 100644 index 0000000000..024dc9a047 --- /dev/null +++ b/test/features/media/domain/entities/media_dive_window_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/domain/entities/media_dive_window.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/services/dive_photo_matcher.dart'; + +MediaEnrichment _enrichment({ + int? elapsedSeconds, + MatchConfidence confidence = MatchConfidence.exact, +}) => MediaEnrichment( + id: 'e1', + mediaId: 'm1', + diveId: 'd1', + elapsedSeconds: elapsedSeconds, + depthMeters: 10, + matchConfidence: confidence, + createdAt: DateTime.utc(2026, 1, 1), +); + +void main() { + group('MediaDiveWindow', () { + test('matches the photo matcher buffers so the two rules stay in step', () { + expect(DivePhotoMatcher.preBuffer, MediaDiveWindow.before); + expect(DivePhotoMatcher.postBuffer, MediaDiveWindow.after); + }); + + test('accepts positions inside the profile', () { + expect( + MediaDiveWindow.contains( + elapsedSeconds: 600, + profileLengthSeconds: 3600, + ), + isTrue, + ); + }); + + test('accepts a surface shot inside the pre-dive buffer', () { + expect( + MediaDiveWindow.contains( + elapsedSeconds: -MediaDiveWindow.before.inSeconds, + profileLengthSeconds: 3600, + ), + isTrue, + ); + expect( + MediaDiveWindow.contains( + elapsedSeconds: -MediaDiveWindow.before.inSeconds - 1, + profileLengthSeconds: 3600, + ), + isFalse, + ); + }); + + test('accepts a debrief shot inside the post-dive buffer', () { + expect( + MediaDiveWindow.contains( + elapsedSeconds: 3600 + MediaDiveWindow.after.inSeconds, + profileLengthSeconds: 3600, + ), + isTrue, + ); + expect( + MediaDiveWindow.contains( + elapsedSeconds: 3600 + MediaDiveWindow.after.inSeconds + 1, + profileLengthSeconds: 3600, + ), + isFalse, + ); + }); + }); + + group('MediaEnrichment.isWithinDiveWindow', () { + test('is false without an elapsed time', () { + expect(_enrichment().isWithinDiveWindow(3600), isFalse); + }); + + test('is false for a noProfile row even with an elapsed time', () { + expect( + _enrichment( + elapsedSeconds: 600, + confidence: MatchConfidence.noProfile, + ).isWithinDiveWindow(3600), + isFalse, + ); + }); + + test('is false for an automatic position days outside the dive', () { + // Issue #1090: a 2016 timestamp on a 2026 dive. + expect( + _enrichment( + elapsedSeconds: -5554653 * 60, + confidence: MatchConfidence.estimated, + ).isWithinDiveWindow(3600), + isFalse, + ); + expect( + _enrichment( + elapsedSeconds: 1879 * 60, + confidence: MatchConfidence.estimated, + ).isWithinDiveWindow(3600), + isFalse, + ); + }); + + test('is true for an automatic position inside the tolerance', () { + expect( + _enrichment( + elapsedSeconds: 3600 + 120, + confidence: MatchConfidence.estimated, + ).isWithinDiveWindow(3600), + isTrue, + ); + }); + + test('is always true for a manual position', () { + // The diver's own placement is never second-guessed by the tolerance. + expect( + _enrichment( + elapsedSeconds: 1879 * 60, + confidence: MatchConfidence.manual, + ).isWithinDiveWindow(3600), + isTrue, + ); + expect( + _enrichment( + elapsedSeconds: 600, + confidence: MatchConfidence.manual, + ).isManual, + isTrue, + ); + }); + }); + + group('MatchConfidence.manual', () { + test('round-trips through its database string', () { + expect(MatchConfidence.fromString('manual'), MatchConfidence.manual); + expect(MatchConfidence.manual.name, 'manual'); + expect(MatchConfidence.manual.displayName, 'Manual'); + }); + }); +} diff --git a/test/features/media/domain/entities/media_item_manual_elapsed_test.dart b/test/features/media/domain/entities/media_item_manual_elapsed_test.dart new file mode 100644 index 0000000000..398003e32a --- /dev/null +++ b/test/features/media/domain/entities/media_item_manual_elapsed_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +void main() { + final now = DateTime.utc(2026, 1, 1); + final base = MediaItem( + id: 'm1', + diveId: 'd1', + mediaType: MediaType.photo, + takenAt: now, + createdAt: now, + updatedAt: now, + ); + + group('MediaItem.manualElapsedSeconds', () { + test('defaults to null, meaning the automatic position applies', () { + expect(base.manualElapsedSeconds, isNull); + }); + + test('copyWith sets and preserves the value', () { + final pinned = base.copyWith(manualElapsedSeconds: 720); + expect(pinned.manualElapsedSeconds, 720); + expect(pinned.copyWith(caption: 'x').manualElapsedSeconds, 720); + }); + + test('copyWith can clear the value back to automatic', () { + final pinned = base.copyWith(manualElapsedSeconds: 720); + expect( + pinned.copyWith(manualElapsedSeconds: null).manualElapsedSeconds, + isNull, + ); + }); + + test('participates in equality', () { + expect(base.copyWith(manualElapsedSeconds: 720), isNot(equals(base))); + }); + }); +} diff --git a/test/features/media/presentation/helpers/elapsed_time_format_test.dart b/test/features/media/presentation/helpers/elapsed_time_format_test.dart new file mode 100644 index 0000000000..5560066dfc --- /dev/null +++ b/test/features/media/presentation/helpers/elapsed_time_format_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/presentation/helpers/elapsed_time_format.dart'; + +/// The Set-time dialog's mm:ss field (issue #1090) and the viewer's elapsed +/// chip share one formatter so what the diver types is what they later see. +void main() { + group('formatElapsedMmSs', () { + test('pads seconds to two digits', () { + expect(formatElapsedMmSs(0), '0:00'); + expect(formatElapsedMmSs(65), '1:05'); + expect(formatElapsedMmSs(3599), '59:59'); + }); + + test('keeps minutes unpadded past an hour', () { + expect(formatElapsedMmSs(3600), '60:00'); + expect(formatElapsedMmSs(7325), '122:05'); + }); + + test('formats a negative offset with a leading minus', () { + expect(formatElapsedMmSs(-90), '-1:30'); + }); + }); + + group('parseElapsedMmSs', () { + test('parses m:ss and mm:ss', () { + expect(parseElapsedMmSs('1:05'), 65); + expect(parseElapsedMmSs('12:30'), 750); + expect(parseElapsedMmSs('122:05'), 7325); + }); + + test('parses bare minutes', () { + expect(parseElapsedMmSs('12'), 720); + }); + + test('tolerates surrounding whitespace', () { + expect(parseElapsedMmSs(' 1:05 '), 65); + }); + + test('rejects malformed input', () { + expect(parseElapsedMmSs(''), isNull); + expect(parseElapsedMmSs('abc'), isNull); + expect(parseElapsedMmSs('1:5:9'), isNull); + expect(parseElapsedMmSs('1:75'), isNull); + expect(parseElapsedMmSs('-1:00'), isNull); + }); + }); +} diff --git a/test/features/media/presentation/helpers/media_time_pinner_test.dart b/test/features/media/presentation/helpers/media_time_pinner_test.dart new file mode 100644 index 0000000000..70a962d851 --- /dev/null +++ b/test/features/media/presentation/helpers/media_time_pinner_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as domain; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/dive_media_enricher.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/presentation/helpers/media_time_pinner.dart'; +import 'package:submersion/features/media/presentation/widgets/set_media_time_dialog.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1090: applying the dialog's choice is one write to the media row +/// followed by one enrichment pass, so the chart and viewer reposition on +/// the next tick rather than waiting for a later backfill. +void main() { + late MediaRepository repository; + late MediaTimePinner pinner; + late String diveId; + + final dive = domain.Dive( + id: '', + dateTime: DateTime.utc(2026, 1, 1, 10), + entryTime: DateTime.utc(2026, 1, 1, 10), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 0), + domain.DiveProfilePoint(timestamp: 600, depth: 20, temperature: 24), + domain.DiveProfilePoint(timestamp: 1200, depth: 0), + ], + ); + + setUp(() async { + await setUpTestDatabase(); + repository = MediaRepository(); + diveId = (await DiveRepository().createDive(dive)).id; + // A real enricher over the real repository; only the dive load is + // stubbed, since the dive's profile is the only thing it needs. + pinner = MediaTimePinner( + repository: repository, + enricher: DiveMediaEnricher( + loadDive: (_) async => dive.copyWith(id: diveId), + loadMediaForDive: repository.getMediaForDive, + saveEnrichments: repository.saveEnrichments, + ), + ); + }); + + tearDown(tearDownTestDatabase); + + Future linkedItem() async { + return repository.createMedia( + MediaItem( + id: '', + diveId: diveId, + filePath: '/photos/a.jpg', + mediaType: MediaType.photo, + // A decade off, as in the report. + takenAt: DateTime.utc(2016, 1, 6, 0, 3), + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ), + ); + } + + test('pinning writes the offset and re-enriches at it', () async { + final item = await linkedItem(); + + await pinner.apply(item, const MediaTimePinned(600)); + + final fetched = (await repository.getMediaById(item.id))!; + expect(fetched.manualElapsedSeconds, 600); + expect(fetched.enrichment?.elapsedSeconds, 600); + expect(fetched.enrichment?.depthMeters, 20); + expect(fetched.enrichment?.matchConfidence, MatchConfidence.manual); + }); + + test( + 'resetting clears the offset and re-enriches from the capture time', + () async { + final item = await linkedItem(); + await pinner.apply(item, const MediaTimePinned(600)); + + await pinner.apply(item, const MediaTimeReset()); + + final fetched = (await repository.getMediaById(item.id))!; + expect(fetched.manualElapsedSeconds, isNull); + expect(fetched.enrichment?.matchConfidence, MatchConfidence.estimated); + expect(fetched.enrichment?.elapsedSeconds, isNot(600)); + }, + ); + + test('an item with no dive link is left untouched', () async { + final item = await repository.createMedia( + MediaItem( + id: '', + filePath: '/photos/b.jpg', + mediaType: MediaType.photo, + takenAt: DateTime.utc(2026), + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ), + ); + + await pinner.apply(item, const MediaTimePinned(600)); + + expect( + (await repository.getMediaById(item.id))!.manualElapsedSeconds, + isNull, + ); + }); +} diff --git a/test/features/media/presentation/pages/media_viewer_manual_time_test.dart b/test/features/media/presentation/pages/media_viewer_manual_time_test.dart new file mode 100644 index 0000000000..5cb535b795 --- /dev/null +++ b/test/features/media/presentation/pages/media_viewer_manual_time_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as domain; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/media/data/services/media_source_resolver_registry.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/services/media_source_resolver.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; +import 'package:submersion/features/media/presentation/pages/media_viewer_page.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/mini_dive_profile_overlay.dart'; +import 'package:submersion/features/media/presentation/widgets/set_media_time_dialog.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1090: the viewer's bottom overlay used to print the raw elapsed +/// offset (`+1879:28`, `+-5554653:32`) for a capture time far outside the +/// dive, with the mini profile dot pinned to the exit. These tests pin the +/// three states the overlay now distinguishes: positioned automatically, +/// positioned by the diver, and not positioned at all. +class _UnavailableResolver implements MediaSourceResolver { + _UnavailableResolver(this.sourceType); + @override + final MediaSourceType sourceType; + @override + bool canResolveOnThisDevice(MediaItem item) => true; + @override + Future resolve(MediaItem item) async => + const UnavailableData(kind: UnavailableKind.notFound); + @override + Future resolveThumbnail( + MediaItem item, { + required Size target, + }) => resolve(item); + @override + Future extractMetadata(MediaItem item) async => null; + @override + Future verify(MediaItem item) async => VerifyResult.available; +} + +MediaItem _item({MediaEnrichment? enrichment}) => MediaItem( + id: 'm1', + diveId: 'd1', + mediaType: MediaType.photo, + sourceType: MediaSourceType.platformGallery, + takenAt: DateTime.utc(2016, 1, 6, 0, 3), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + enrichment: enrichment, +); + +MediaEnrichment _enrichment( + int elapsedSeconds, { + MatchConfidence confidence = MatchConfidence.exact, +}) => MediaEnrichment( + id: 'e-m1', + mediaId: 'm1', + diveId: 'd1', + elapsedSeconds: elapsedSeconds, + depthMeters: 15.0, + matchConfidence: confidence, + createdAt: DateTime.utc(2026, 7, 1), +); + +final _dive = domain.Dive( + id: 'd1', + dateTime: DateTime.utc(2026, 7, 1, 9, 30), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 0.0), + domain.DiveProfilePoint(timestamp: 60, depth: 10.0), + domain.DiveProfilePoint(timestamp: 120, depth: 20.0), + domain.DiveProfilePoint(timestamp: 180, depth: 15.0), + domain.DiveProfilePoint(timestamp: 240, depth: 5.0), + ], +); + +void main() { + late SharedPreferences prefs; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + tearDown(tearDownTestDatabase); + + Future pump(WidgetTester tester, MediaItem item) async { + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + mediaSourceResolverRegistryProvider.overrideWithValue( + MediaSourceResolverRegistry({ + MediaSourceType.platformGallery: _UnavailableResolver( + MediaSourceType.platformGallery, + ), + }), + ), + diveProvider('d1').overrideWith((ref) async => _dive), + mediaByIdProvider('m1').overrideWith((ref) async => item), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MediaViewerPage(mediaList: [item], initialMediaId: 'm1'), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); + }); + } + + testWidgets('an automatic position inside the dive shows the elapsed chip, ' + 'depth and mini profile', (tester) async { + await pump(tester, _item(enrichment: _enrichment(180))); + + expect(find.text('+3:00'), findsOneWidget); + expect(find.byIcon(Icons.arrow_downward), findsOneWidget); + expect(find.byType(MiniDiveProfileOverlay), findsOneWidget); + }); + + testWidgets('a position days outside the dive shows unknown instead of a ' + 'raw offset', (tester) async { + await pump( + tester, + _item( + enrichment: _enrichment( + 1879 * 60, + confidence: MatchConfidence.estimated, + ), + ), + ); + + expect(find.text('Time in dive unknown'), findsOneWidget); + expect(find.textContaining('+1879'), findsNothing); + expect(find.byIcon(Icons.arrow_downward), findsNothing); + expect(find.byType(MiniDiveProfileOverlay), findsNothing); + expect(find.text('Estimated'), findsNothing); + }); + + testWidgets('a manual position shows the pin and no confidence warning', ( + tester, + ) async { + await pump( + tester, + _item(enrichment: _enrichment(180, confidence: MatchConfidence.manual)), + ); + + expect(find.text('+3:00'), findsOneWidget); + expect(find.byIcon(Icons.push_pin_outlined), findsOneWidget); + expect(find.text('Manual'), findsNothing); + expect(find.byType(MiniDiveProfileOverlay), findsOneWidget); + }); + + testWidgets('tapping the elapsed chip opens the Set time dialog', ( + tester, + ) async { + await pump(tester, _item(enrichment: _enrichment(180))); + + await tester.tap(find.text('+3:00')); + await tester.pumpAndSettle(); + + expect(find.byType(SetMediaTimeDialog), findsOneWidget); + }); + + testWidgets('tapping the unknown chip opens the Set time dialog', ( + tester, + ) async { + await pump( + tester, + _item( + enrichment: _enrichment( + 1879 * 60, + confidence: MatchConfidence.estimated, + ), + ), + ); + + await tester.tap(find.text('Time in dive unknown')); + await tester.pumpAndSettle(); + + expect(find.byType(SetMediaTimeDialog), findsOneWidget); + }); +} diff --git a/test/features/media/presentation/widgets/media_info_panel_test.dart b/test/features/media/presentation/widgets/media_info_panel_test.dart index ec6a46e956..64c547e239 100644 --- a/test/features/media/presentation/widgets/media_info_panel_test.dart +++ b/test/features/media/presentation/widgets/media_info_panel_test.dart @@ -14,7 +14,12 @@ import 'package:submersion/features/media/domain/value_objects/media_source_data import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_provenance_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_serving_providers.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as domain; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/media/presentation/helpers/media_time_pinner.dart'; import 'package:submersion/features/media/presentation/widgets/media_info_panel.dart'; +import 'package:submersion/features/media/presentation/widgets/set_media_time_dialog.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -91,6 +96,20 @@ class _CapturingQueue implements MediaTransferQueueRepository { throw UnimplementedError('${invocation.memberName} is not stubbed'); } +class _CapturingPinner implements MediaTimePinner { + _CapturingPinner(this.applied); + final List applied; + + @override + Future apply(MediaItem item, MediaTimeChoice choice) async { + applied.add(choice); + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName} is not stubbed'); +} + void main() { late String? previousDefaultLocale; late MediaServingRecorder recorder; @@ -619,4 +638,118 @@ void main() { expect(find.text('Retry upload'), findsNothing); }); }); + + group('Time in dive (issue #1090)', () { + final dive = domain.Dive( + id: 'd1', + dateTime: DateTime.utc(2026, 3, 12, 9), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 0), + domain.DiveProfilePoint(timestamp: 600, depth: 20), + domain.DiveProfilePoint(timestamp: 1800, depth: 0), + ], + ); + + MediaItem linked({MediaEnrichment? enrichment}) => MediaItem( + id: 'm1', + diveId: 'd1', + mediaType: MediaType.photo, + sourceType: MediaSourceType.platformGallery, + platformAssetId: 'asset-1', + takenAt: DateTime(2026, 3, 12, 9, 14), + createdAt: DateTime(2026, 3, 12), + updatedAt: DateTime(2026, 3, 12), + enrichment: enrichment, + ); + + MediaEnrichment enrichmentAt( + int elapsedSeconds, { + MatchConfidence confidence = MatchConfidence.exact, + }) => MediaEnrichment( + id: 'e1', + mediaId: 'm1', + diveId: 'd1', + elapsedSeconds: elapsedSeconds, + depthMeters: 12, + matchConfidence: confidence, + createdAt: DateTime(2026, 3, 12), + ); + + final diveOverride = diveProvider('d1').overrideWith((ref) async => dive); + + testWidgets('renders the automatic position as mm:ss', (tester) async { + await pump( + tester, + linked(enrichment: enrichmentAt(750)), + extra: [diveOverride], + ); + + expect(find.text('Time in dive'), findsOneWidget); + expect(find.text('12:30'), findsOneWidget); + }); + + testWidgets('marks a manual position as set by the diver', (tester) async { + await pump( + tester, + linked( + enrichment: enrichmentAt(750, confidence: MatchConfidence.manual), + ), + extra: [diveOverride], + ); + + expect(find.text('12:30 (set manually)'), findsOneWidget); + }); + + testWidgets('renders Unknown for a position outside the dive window', ( + tester, + ) async { + await pump( + tester, + linked( + enrichment: enrichmentAt( + 1879 * 60, + confidence: MatchConfidence.estimated, + ), + ), + extra: [diveOverride], + ); + + expect(find.text('Time in dive'), findsOneWidget); + expect(find.text('1879:00'), findsNothing); + }); + + testWidgets('offers no row or action for an item with no dive', ( + tester, + ) async { + await pump(tester, _item()); + + expect(find.text('Time in dive'), findsNothing); + expect(find.text('Set time in dive'), findsNothing); + }); + + testWidgets('Set time in dive opens the dialog and applies the choice', ( + tester, + ) async { + final applied = []; + await pump( + tester, + linked(enrichment: enrichmentAt(750)), + extra: [ + diveOverride, + mediaTimePinnerProvider.overrideWithValue(_CapturingPinner(applied)), + ], + ); + + await tester.tap(find.text('Set time in dive')); + await tester.pumpAndSettle(); + expect(find.byType(SetMediaTimeDialog), findsOneWidget); + + await tester.enterText(find.byType(TextField), '5:00'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(applied, hasLength(1)); + expect((applied.single as MediaTimePinned).elapsedSeconds, 300); + }); + }); } diff --git a/test/features/media/presentation/widgets/set_media_time_dialog_test.dart b/test/features/media/presentation/widgets/set_media_time_dialog_test.dart new file mode 100644 index 0000000000..524227d349 --- /dev/null +++ b/test/features/media/presentation/widgets/set_media_time_dialog_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/media/presentation/widgets/mini_dive_profile_overlay.dart'; +import 'package:submersion/features/media/presentation/widgets/set_media_time_dialog.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Issue #1090: the dialog where a diver pins a media item to a moment in +/// the dive. It is a plain widget over a profile and a starting offset; the +/// caller persists whatever it returns. +const _profile = [ + DiveProfilePoint(timestamp: 0, depth: 0), + DiveProfilePoint(timestamp: 600, depth: 20, temperature: 24), + DiveProfilePoint(timestamp: 1200, depth: 10), + DiveProfilePoint(timestamp: 1800, depth: 0), +]; + +void main() { + MediaTimeChoice? result; + var closed = false; + + Future pump( + WidgetTester tester, { + int initialElapsedSeconds = 0, + bool isPinned = false, + List profile = _profile, + }) async { + result = null; + closed = false; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + result = await showSetMediaTimeDialog( + context, + profile: profile, + initialElapsedSeconds: initialElapsedSeconds, + isPinned: isPinned, + settings: const AppSettings(), + ); + closed = true; + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + Finder field() => find.byType(TextField); + + testWidgets('opens on the starting offset with the profile length as the ' + 'range', (tester) async { + await pump(tester, initialElapsedSeconds: 750); + + expect(find.byType(SetMediaTimeDialog), findsOneWidget); + expect(tester.widget(field()).controller!.text, '12:30'); + expect(find.textContaining('30:00'), findsOneWidget); + expect(tester.widget(find.byType(Slider)).value, 750); + expect(tester.widget(find.byType(Slider)).max, 1800); + }); + + testWidgets('previews the moment on the mini profile as it changes', ( + tester, + ) async { + await pump(tester, initialElapsedSeconds: 0); + + await tester.enterText(field(), '10:00'); + await tester.pump(); + + final overlay = tester.widget( + find.byType(MiniDiveProfileOverlay), + ); + expect(overlay.photoElapsedSeconds, 600); + expect(overlay.photoDepthMeters, 20); + }); + + testWidgets('moving the slider rewrites the field', (tester) async { + await pump(tester, initialElapsedSeconds: 0); + + final slider = tester.widget(find.byType(Slider)); + slider.onChanged!(900); + await tester.pump(); + + expect(tester.widget(field()).controller!.text, '15:00'); + }); + + testWidgets('Save returns the typed offset', (tester) async { + await pump(tester); + + await tester.enterText(field(), '7:05'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(closed, isTrue); + expect(result, isA()); + expect((result! as MediaTimePinned).elapsedSeconds, 425); + }); + + testWidgets('an offset past the end of the dive is refused', (tester) async { + await pump(tester); + + await tester.enterText(field(), '31:00'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(closed, isFalse); + expect(find.byType(SetMediaTimeDialog), findsOneWidget); + expect(find.text('Enter a time between 0:00 and 30:00'), findsOneWidget); + }); + + testWidgets('malformed input is refused', (tester) async { + await pump(tester); + + await tester.enterText(field(), 'noon'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(closed, isFalse); + expect(find.text('Enter a time between 0:00 and 30:00'), findsOneWidget); + }); + + testWidgets('Cancel returns nothing', (tester) async { + await pump(tester); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(closed, isTrue); + expect(result, isNull); + }); + + testWidgets('Reset to automatic is offered only for a pinned item', ( + tester, + ) async { + await pump(tester, isPinned: false); + expect(find.text('Reset to automatic'), findsNothing); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + await pump(tester, isPinned: true, initialElapsedSeconds: 600); + await tester.tap(find.text('Reset to automatic')); + await tester.pumpAndSettle(); + + expect(closed, isTrue); + expect(result, isA()); + }); +} From ecd6818be3560d55c776c235b6eec86a2e6e58dc Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:27:12 -0400 Subject: [PATCH 039/122] style(auto_update): group package imports above local ones Per review on #1286. update_providers already had submersion/core/providers/provider.dart above package_info_plus before this branch touched it, and adding the app_version import next to it made the misordered block bigger. Move the package import to its own group above the submersion ones so the file matches the convention in CLAUDE.md and the shape used by startup_page and log_environment. Import-only change; no behaviour difference. --- .../auto_update/presentation/providers/update_providers.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/auto_update/presentation/providers/update_providers.dart b/lib/features/auto_update/presentation/providers/update_providers.dart index 1580a3e70a..ebee0a3570 100644 --- a/lib/features/auto_update/presentation/providers/update_providers.dart +++ b/lib/features/auto_update/presentation/providers/update_providers.dart @@ -1,9 +1,9 @@ import 'dart:io'; -import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/core/utils/app_version.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/utils/app_version.dart'; import 'package:submersion/features/auto_update/data/repositories/update_preferences.dart'; import 'package:submersion/features/auto_update/data/services/github_update_service.dart'; import 'package:submersion/features/auto_update/data/services/sparkle_update_service.dart'; From d0bc365fe62211e296b44e957c63588024947ac3 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:35:10 -0400 Subject: [PATCH 040/122] fix(icloud): upload backups to the backup folder, not the sync folder ICloudStorageProvider accepted a folderId on uploadFile and uploadFileFromPath and then ignored it, always joining the filename onto getOrCreateSyncFolder(). BackupService resolves "Submersion Backups" and passes it as folderId, so every cloud backup was filed among the sync files, while getCloudBackups() listed the (empty) backup folder and showed nothing. Reads and writes disagreed and neither raised an error. Route both uploads through _resolveUploadFolder, which honours folderId and falls back to the sync folder, matching Google Drive, S3 and Dropbox. That also collapses the duplicated 15s timeout block the two methods had drifted into separate copies of. Add an ICloudContainerFileWrite seam so uploadFile is reachable from a non-Apple test host: ICloudNativeService.writeFile throws off-Apple, so that method previously had no test coverage at all. Same rationale as the existing containerFileMove and ensureDownloaded seams. Every sync-side folderId originates from getOrCreateSyncFolder() itself, so honouring the parameter is a no-op for sync and changes only the backup destination. Fixes #653 --- .../icloud_storage_provider.dart | 67 ++++++----- .../icloud_storage_provider_test.dart | 107 ++++++++++++++++++ 2 files changed, 147 insertions(+), 27 deletions(-) diff --git a/lib/core/services/cloud_storage/icloud_storage_provider.dart b/lib/core/services/cloud_storage/icloud_storage_provider.dart index a5d143bb94..e12b094d06 100644 --- a/lib/core/services/cloud_storage/icloud_storage_provider.dart +++ b/lib/core/services/cloud_storage/icloud_storage_provider.dart @@ -40,6 +40,11 @@ typedef ICloudContainerPathLookup = Future Function(); typedef ICloudContainerFileMove = Future Function(String sourcePath, String destinationPath); +/// Writes bytes into the container with file coordination; throws when the +/// write could not be performed. +typedef ICloudContainerFileWrite = + Future Function(String path, Uint8List data); + /// Materializes an iCloud container file locally before it is read; false /// when the file could not be downloaded. typedef ICloudFileDownload = Future Function(String path); @@ -66,20 +71,24 @@ class ICloudStorageProvider /// reaching the channel on other hosts — so on a Linux CI runner a mocked /// channel is never consulted, and a test would reach its assertion via a /// short circuit rather than via the branch it means to exercise. - /// [containerFileMove] and [ensureDownloaded] default to the native channel - /// calls and are injectable for the same reason as [containerPathLookup]: - /// both carry their own Apple-platform guard and short-circuit without - /// reaching the channel on other hosts, so a mocked channel would go - /// unconsulted on the Linux CI runner. + /// [containerFileMove], [containerFileWrite] and [ensureDownloaded] default + /// to the native channel calls and are injectable for the same reason as + /// [containerPathLookup]: each carries its own Apple-platform guard and + /// short-circuits (or, for the write, throws) without reaching the channel on + /// other hosts, so a mocked channel would go unconsulted on the Linux CI + /// runner. ICloudStorageProvider({ ICloudHostPlatform? platform, ICloudContainerPathLookup? containerPathLookup, ICloudContainerFileMove? containerFileMove, + ICloudContainerFileWrite? containerFileWrite, ICloudFileDownload? ensureDownloaded, }) : _platform = platform ?? ICloudHostPlatform.current(), _lookupContainerPath = containerPathLookup ?? ICloudNativeService.getContainerPath, _moveIntoContainer = containerFileMove ?? ICloudNativeService.moveFile, + _writeIntoContainer = + containerFileWrite ?? ICloudNativeService.writeFile, _ensureDownloaded = ensureDownloaded ?? ICloudNativeService.downloadIfNeeded; @@ -88,6 +97,7 @@ class ICloudStorageProvider final ICloudHostPlatform _platform; final ICloudContainerPathLookup _lookupContainerPath; final ICloudContainerFileMove _moveIntoContainer; + final ICloudContainerFileWrite _writeIntoContainer; final ICloudFileDownload _ensureDownloaded; Directory? _icloudContainer; @@ -193,6 +203,23 @@ class ICloudStorageProvider } } + /// The container path an upload should land in. + /// + /// On iCloud a folder id IS a container path (that is what [createFolder] + /// returns), so honouring [folderId] is a plain substitution. Dropping it + /// instead filed database backups among the sync files, where the backup + /// UI (which lists by that same folder id) could never see them again + /// (issue #653). + Future _resolveUploadFolder(String? folderId) async { + if (folderId != null) return folderId; + return getOrCreateSyncFolder().timeout( + const Duration(seconds: 15), + onTimeout: () { + throw const CloudStorageException('Timeout getting sync folder (15s)'); + }, + ); + } + @override Future uploadFile( Uint8List data, @@ -202,24 +229,17 @@ class ICloudStorageProvider try { _log.info('uploadFile: START for $filename (${data.length} bytes)'); - // Step 1: Get sync folder with timeout - _log.info('uploadFile: Step 1 - getting sync folder...'); - final syncFolder = await getOrCreateSyncFolder().timeout( - const Duration(seconds: 15), - onTimeout: () { - throw const CloudStorageException( - 'Timeout getting sync folder (15s)', - ); - }, - ); - _log.info('uploadFile: Step 1 DONE - sync folder: $syncFolder'); + // Step 1: Resolve the destination folder with timeout + _log.info('uploadFile: Step 1 - resolving destination folder...'); + final targetFolder = await _resolveUploadFolder(folderId); + _log.info('uploadFile: Step 1 DONE - destination: $targetFolder'); - final filePath = path.join(syncFolder, filename); + final filePath = path.join(targetFolder, filename); // Step 2: Write directly to iCloud using native file coordination // Native code handles direct file write with timeout _log.info('uploadFile: Step 2 - writing to iCloud via native: $filePath'); - await ICloudNativeService.writeFile(filePath, data).timeout( + await _writeIntoContainer(filePath, data).timeout( const Duration(seconds: 30), onTimeout: () { throw const CloudStorageException('Timeout writing to iCloud (30s)'); @@ -274,15 +294,8 @@ class ICloudStorageProvider String? folderId, }) async { try { - final syncFolder = await getOrCreateSyncFolder().timeout( - const Duration(seconds: 15), - onTimeout: () { - throw const CloudStorageException( - 'Timeout getting sync folder (15s)', - ); - }, - ); - final filePath = path.join(syncFolder, filename); + final targetFolder = await _resolveUploadFolder(folderId); + final filePath = path.join(targetFolder, filename); // Copy next to the destination so the coordinated move is same-volume, // mirroring ICloudMediaObjectStore.putFile. The copy itself sits inside diff --git a/test/core/services/cloud_storage/icloud_storage_provider_test.dart b/test/core/services/cloud_storage/icloud_storage_provider_test.dart index bb58e1f0f1..c1bf1f124f 100644 --- a/test/core/services/cloud_storage/icloud_storage_provider_test.dart +++ b/test/core/services/cloud_storage/icloud_storage_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -269,6 +270,112 @@ void main() { ); }); + group('upload target folder', () { + // BackupService resolves 'Submersion Backups' via createFolder and hands + // it to the upload as folderId. Every other provider honours that + // parameter; iCloud silently dropped it and filed every backup among the + // sync files (issue #653). + late String containerPath; + late List writtenPaths; + + setUp(() { + containerPath = p.join(tempDir.path, 'container'); + Directory(containerPath).createSync(recursive: true); + writtenPaths = []; + }); + + String syncFolderPath() => + p.join(containerPath, CloudStorageProviderMixin.syncFolderName); + + String backupFolderPath() => p.join(containerPath, 'Submersion Backups'); + + ICloudStorageProvider uploadProvider() { + return ICloudStorageProvider( + platform: ICloudHostPlatform.ios, + containerPathLookup: () async => containerPath, + containerFileMove: (source, destination) async { + File(source).renameSync(destination); + return true; + }, + containerFileWrite: (path, data) async { + writtenPaths.add(path); + await File(path).writeAsBytes(data); + }, + ); + } + + test( + 'uploadFileFromPath writes into the folder the caller named', + () async { + Directory(backupFolderPath()).createSync(); + final src = File(p.join(tempDir.path, 'backup.db')) + ..writeAsStringSync('payload'); + + final result = await uploadProvider().uploadFileFromPath( + src.path, + 'submersion_backup_x.db', + folderId: backupFolderPath(), + ); + + expect( + result.fileId, + p.join(backupFolderPath(), 'submersion_backup_x.db'), + ); + expect(File(result.fileId).readAsStringSync(), 'payload'); + expect( + Directory(syncFolderPath()).existsSync(), + isFalse, + reason: 'a backup upload must not even reach the sync folder', + ); + }, + ); + + test('uploadFileFromPath falls back to the sync folder when the caller ' + 'names none', () async { + final src = File(p.join(tempDir.path, 'sync.json')) + ..writeAsStringSync('{}'); + + final result = await uploadProvider().uploadFileFromPath( + src.path, + 'submersion_sync.json', + ); + + expect(result.fileId, p.join(syncFolderPath(), 'submersion_sync.json')); + }); + + test('uploadFile writes into the folder the caller named', () async { + Directory(backupFolderPath()).createSync(); + + final result = await uploadProvider().uploadFile( + Uint8List.fromList([1, 2, 3]), + 'submersion_backup_x.db', + folderId: backupFolderPath(), + ); + + expect( + result.fileId, + p.join(backupFolderPath(), 'submersion_backup_x.db'), + ); + expect(writtenPaths, [result.fileId]); + expect( + Directory(syncFolderPath()).existsSync(), + isFalse, + reason: 'a backup upload must not even reach the sync folder', + ); + }); + + test('uploadFile falls back to the sync folder when the caller names ' + 'none', () async { + final result = await uploadProvider().uploadFile( + Uint8List.fromList([1, 2, 3]), + 'submersion_sync.json', + ); + + expect(result.fileId, p.join(syncFolderPath(), 'submersion_sync.json')); + expect(writtenPaths, [result.fileId]); + }); + }); + group('ICloudHostPlatform', () { test('treats both Apple platforms as Apple', () { expect(ICloudHostPlatform.ios.isApple, isTrue); From b68b11bafcee12f223ed73680079c154a53205dd Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:40:39 -0400 Subject: [PATCH 041/122] test(dive-log): cover re-picking a present buddy's role in bulk edit Issue #700 reported that re-picking an already-present buddy through the bulk editor's Add picker with a different role left the old role in place. The three-way split of the buddy ops in #1220 fixed it: a picked role with no membership change now routes to a BulkCollectionMode.update op that rewrites the existing links without creating any. That path had no test. The suite covered the inline per-row role button added by #1220, but never the picker re-pick flow #700 actually described. This adds a regression test that drives the issue's literal steps and asserts both links are re-roled in place rather than duplicated. --- .../pages/bulk_dive_edit_form_test.dart | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/test/features/dive_log/presentation/pages/bulk_dive_edit_form_test.dart b/test/features/dive_log/presentation/pages/bulk_dive_edit_form_test.dart index c9036e0c5e..de5fdf7728 100644 --- a/test/features/dive_log/presentation/pages/bulk_dive_edit_form_test.dart +++ b/test/features/dive_log/presentation/pages/bulk_dive_edit_form_test.dart @@ -406,6 +406,96 @@ void main() { expect(await BuddyRepository().getBuddiesForDive(d2.id), isEmpty); }); + testWidgets('re-picking an already-present buddy with a new role rewrites ' + 'that role (#700)', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final buddy = await BuddyRepository().createBuddy( + Buddy( + id: '', + name: 'Casey Diver', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ), + ); + final d1 = await repository.createDive( + createTestDiveWithBottomTime().copyWith(id: 'repick-role-1'), + ); + final d2 = await repository.createDive( + createTestDiveWithBottomTime().copyWith(id: 'repick-role-2'), + ); + // Both dives already carry the buddy as a plain Buddy. + await BuddyRepository().bulkAddBuddies( + [d1.id, d2.id], + [BuddyWithRole(buddy: buddy, role: DiveRole.builtInBuddy())], + ); + + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + ProviderScope( + overrides: buildOverrides(overrides).cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: DiveEditPage(bulkDiveIds: [d1.id, d2.id], embedded: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Go through Add and re-pick the buddy who is already on both dives, + // this time as Instructor. + final buddiesSection = find.ancestor( + of: find.text('Buddies'), + matching: find.byType(BulkMembershipEditor), + ); + final addToBuddies = find.descendant( + of: buddiesSection, + matching: find.byIcon(Icons.add), + ); + await tester.ensureVisible(addToBuddies); + await tester.tap(addToBuddies); + await tester.pumpAndSettle(); + + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byIcon(Icons.add), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(buddy.name).last); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Instructor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Add').last); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Save')); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + // The existing links are re-roled in place, not duplicated. + for (final id in [d1.id, d2.id]) { + final saved = await BuddyRepository().getBuddiesForDive(id); + expect(saved, hasLength(1)); + expect(saved.single.role.id, DiveRole.instructorId); + } + }); + testWidgets('toggling a gate enables its checkbox', (tester) async { await pumpBulk(tester); From e52a7db7838a0c6241264882d63a0a8499677d3e Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:44:56 -0400 Subject: [PATCH 042/122] fix(media): keep the minus on a negative elapsed chip; hu hint reads mm:ss The viewer chip prepended '+' unconditionally, so a surface shot inside the pre-dive buffer rendered as '+-1:30'. Add the plus only for non-negative offsets, with a viewer test for the negative case. The Hungarian Set-time field hint said 'pp:mm' while the field accepts only the mm:ss pattern; align it with the other locales. --- .../media/presentation/pages/media_viewer_page.dart | 7 ++++++- lib/l10n/arb/app_hu.arb | 2 +- lib/l10n/arb/app_localizations_hu.dart | 2 +- .../pages/media_viewer_manual_time_test.dart | 13 +++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/features/media/presentation/pages/media_viewer_page.dart b/lib/features/media/presentation/pages/media_viewer_page.dart index 6d365160eb..b14f5486d4 100644 --- a/lib/features/media/presentation/pages/media_viewer_page.dart +++ b/lib/features/media/presentation/pages/media_viewer_page.dart @@ -1631,7 +1631,12 @@ class _BottomMetadataOverlay extends StatelessWidget { ); } - String _formatElapsedTime(int seconds) => '+${formatElapsedMmSs(seconds)}'; + /// `+3:00` into the dive, `-1:30` for a surface shot just before it; the + /// formatter already carries the minus, so the plus is only for the rest. + String _formatElapsedTime(int seconds) { + final formatted = formatElapsedMmSs(seconds); + return seconds < 0 ? formatted : '+$formatted'; + } } /// Small metadata chip with icon and value. diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 6e137b5dcc..361a38da36 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -9636,7 +9636,7 @@ "media_timeInDive_setAction": "Időpont beállítása a merülésben", "media_timeInDive_manual": "{time} (kézzel beállítva)", "media_timeInDive_fieldLabel": "Idő a merülés kezdetétől", - "media_timeInDive_fieldHint": "pp:mm", + "media_timeInDive_fieldHint": "mm:ss", "media_timeInDive_range": "0:00 és {max} között", "media_timeInDive_invalid": "Adj meg egy időt 0:00 és {max} között", "media_timeInDive_save": "Mentes", diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 0fb937dd70..44348f2d3f 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -32697,7 +32697,7 @@ class AppLocalizationsHu extends AppLocalizations { String get media_timeInDive_fieldLabel => 'Idő a merülés kezdetétől'; @override - String get media_timeInDive_fieldHint => 'pp:mm'; + String get media_timeInDive_fieldHint => 'mm:ss'; @override String media_timeInDive_range(String max) { diff --git a/test/features/media/presentation/pages/media_viewer_manual_time_test.dart b/test/features/media/presentation/pages/media_viewer_manual_time_test.dart index 5cb535b795..07498643df 100644 --- a/test/features/media/presentation/pages/media_viewer_manual_time_test.dart +++ b/test/features/media/presentation/pages/media_viewer_manual_time_test.dart @@ -134,6 +134,19 @@ void main() { expect(find.byType(MiniDiveProfileOverlay), findsOneWidget); }); + testWidgets('a surface shot just before the dive reads as a negative offset, ' + 'not +-', (tester) async { + await pump( + tester, + _item( + enrichment: _enrichment(-90, confidence: MatchConfidence.estimated), + ), + ); + + expect(find.text('-1:30'), findsOneWidget); + expect(find.textContaining('+-'), findsNothing); + }); + testWidgets('a position days outside the dive shows unknown instead of a ' 'raw offset', (tester) async { await pump( From fdefb05f37316228696e244f835e9820c3b3e0a9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:45:12 -0400 Subject: [PATCH 043/122] feat(sync): resolve foreign keys in the conflict dialog (#1031) The Resolve Conflicts dialog described dives, sites and gear well because those entities carry a name and a date directly. For any junction or relation entity it fell back to dumping raw columns, so a dive_tags conflict read as two UUIDs and an epoch integer with no indication of which dive or which tag, and a quality finding showed its detector id and raw params JSON. There was nothing to decide Keep Local / Keep Remote on. Add a lookup step that runs before the dialog renders. ConflictReferenceResolver maps a record's foreign-key columns to the entity they point at (transcribed from the .references() clauses in database.dart) and resolves each one through SyncDataSerializer.fetchRecord, the same method that already loads the local side of a conflict. Each reference comes back with the referenced row's name, its date, or neither -- the last case meaning the row is not in the local library, which the dialog now says explicitly instead of rendering blank. An unnamed dive borrows its site's name, matching how the app names dives everywhere else. SyncConflict carries localReferences and remoteReferences separately, because for a junction row the foreign keys are usually exactly what the two sides disagree about. Resolution failures degrade to an unresolved preview and log a warning rather than dropping the conflict, which would leave it unresolvable. The preview now leads with the resolved references, hides sync bookkeeping (id, hlc, device columns) and the columns already rendered as references, and formats what remains: epoch millis as the diver's date format, flags as yes/no. A column is only read as a date when its name and its magnitude agree, so bottomTime and runtime (seconds) are not dated to 1970. Quality findings reuse the data-quality feature's buildFindingMessage, so a conflict shows the same localized sentence the inbox shows rather than a detector id and a params blob. The header names a junction row by the records it points at. Adds 36 localized strings across all 11 locales. --- .../services/sync/conflict_reference.dart | 186 +++++++++++++ lib/core/services/sync/sync_service.dart | 42 +++ .../widgets/conflict_data_preview.dart | 250 ++++++++++++++++++ .../widgets/conflict_reference_labels.dart | 132 +++++++++ .../widgets/conflict_resolution_dialog.dart | 115 ++------ lib/l10n/arb/app_ar.arb | 36 +++ lib/l10n/arb/app_de.arb | 36 +++ lib/l10n/arb/app_en.arb | 46 ++++ lib/l10n/arb/app_es.arb | 36 +++ lib/l10n/arb/app_fr.arb | 36 +++ lib/l10n/arb/app_he.arb | 36 +++ lib/l10n/arb/app_hu.arb | 36 +++ lib/l10n/arb/app_it.arb | 36 +++ lib/l10n/arb/app_localizations.dart | 216 +++++++++++++++ lib/l10n/arb/app_localizations_ar.dart | 111 ++++++++ lib/l10n/arb/app_localizations_de.dart | 112 ++++++++ lib/l10n/arb/app_localizations_en.dart | 111 ++++++++ lib/l10n/arb/app_localizations_es.dart | 115 ++++++++ lib/l10n/arb/app_localizations_fr.dart | 113 ++++++++ lib/l10n/arb/app_localizations_he.dart | 111 ++++++++ lib/l10n/arb/app_localizations_hu.dart | 113 ++++++++ lib/l10n/arb/app_localizations_it.dart | 112 ++++++++ lib/l10n/arb/app_localizations_nl.dart | 111 ++++++++ lib/l10n/arb/app_localizations_pt.dart | 113 ++++++++ lib/l10n/arb/app_localizations_zh.dart | 110 ++++++++ lib/l10n/arb/app_nl.arb | 36 +++ lib/l10n/arb/app_pt.arb | 36 +++ lib/l10n/arb/app_zh.arb | 36 +++ .../conflict_reference_resolver_test.dart | 214 +++++++++++++++ .../sync/sync_conflict_resolution_test.dart | 40 +++ .../conflict_resolution_dialog_test.dart | 226 ++++++++++++++++ 31 files changed, 2969 insertions(+), 90 deletions(-) create mode 100644 lib/core/services/sync/conflict_reference.dart create mode 100644 lib/features/settings/presentation/widgets/conflict_data_preview.dart create mode 100644 lib/features/settings/presentation/widgets/conflict_reference_labels.dart create mode 100644 test/core/services/sync/conflict_reference_resolver_test.dart create mode 100644 test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart diff --git a/lib/core/services/sync/conflict_reference.dart b/lib/core/services/sync/conflict_reference.dart new file mode 100644 index 0000000000..f68d83fe1d --- /dev/null +++ b/lib/core/services/sync/conflict_reference.dart @@ -0,0 +1,186 @@ +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; + +/// A foreign-key column of a conflicting record, resolved to whatever +/// real-world anchor the row it points at carries. +/// +/// Junction and relation entities (`diveTags`, `qualityFindings`, ...) store +/// nothing but ids, so the Resolve Conflicts dialog has no way to describe them +/// from the raw record alone (#1031). Resolving the reference gives the dialog +/// a tag's name or a dive's date to show instead of a UUID. +class ConflictReference { + const ConflictReference({ + required this.field, + required this.targetType, + required this.recordId, + this.name, + this.timestamp, + }); + + /// The column on the conflicting record, e.g. `diveId`. + final String field; + + /// The sync entity type the column points at, e.g. `dives`. + final String targetType; + + /// The referenced row's id. + final String recordId; + + /// The referenced row's human name, when it has one. A dive borrows its + /// site's name, matching how the rest of the app names an unnamed dive. + final String? name; + + /// The referenced row's date anchor, for entities dated rather than named. + final DateTime? timestamp; + + /// True when the referenced row is not in the local database: it was deleted + /// here, or the conflicting record arrived from a peer that still has it. + /// The dialog says so rather than showing a blank line. + bool get isMissing => name == null && timestamp == null; +} + +/// Resolves the foreign keys of a conflicting record into [ConflictReference]s. +/// +/// Lookups go through [SyncDataSerializer.fetchRecord], the same method that +/// loads the conflicting row itself, so every entity the serializer can sync is +/// resolvable without a second query layer. +class ConflictReferenceResolver { + const ConflictReferenceResolver(this._serializer); + + final SyncDataSerializer _serializer; + + /// Foreign-key column -> sync entity type, transcribed from the + /// `.references(Table, #id)` clauses in `database.dart`. Columns whose name + /// is ambiguous across tables are disambiguated by [_targetOverrides]. + static const _defaultTargets = { + 'diveId': 'dives', + 'relatedDiveId': 'dives', + 'linkedDiveId': 'dives', + 'sourceDiveId': 'dives', + 'siteId': 'diveSites', + 'tagId': 'tags', + 'diveTypeId': 'diveTypes', + 'diverId': 'divers', + 'buddyId': 'buddies', + 'instructorId': 'buddies', + 'signerId': 'buddies', + 'equipmentId': 'equipment', + 'setId': 'equipmentSets', + 'equipmentSetId': 'equipmentSets', + 'configId': 'cylinderConfigs', + 'computerId': 'diveComputers', + 'sourceId': 'diveDataSources', + 'tankId': 'diveTanks', + 'switchToTankId': 'divePlanTanks', + 'planId': 'divePlans', + 'tripId': 'trips', + 'diveCenterId': 'diveCenters', + 'courseId': 'courses', + 'certificationId': 'certifications', + 'requirementId': 'courseRequirements', + 'serviceKindId': 'serviceKinds', + 'speciesId': 'species', + 'sightingId': 'sightings', + 'mediaId': 'media', + 'subscriptionId': 'mediaSubscriptions', + 'connectorAccountId': 'connectedAccounts', + 'sessionId': 'preDiveSessions', + 'templateId': 'checklistTemplates', + }; + + /// Owning entity type -> column -> target, for the two column names the + /// schema reuses across unrelated tables. + static const _targetOverrides = >{ + 'divePlanSegments': {'tankId': 'divePlanTanks'}, + 'preDiveSessions': {'templateId': 'preDiveChecklistTemplates'}, + 'preDiveChecklistTemplateItems': { + 'templateId': 'preDiveChecklistTemplates', + }, + }; + + /// Name-carrying columns, in the order the app prefers them. + static const _nameFields = [ + 'name', + 'title', + 'commonName', + 'caption', + 'originalFilename', + ]; + + /// Date-carrying columns (Unix millis) for entities that are dated, not + /// named. + static const _timestampFields = [ + 'diveDateTime', + 'startDateTime', + 'startDate', + ]; + + /// The entity type [field] on an [entityType] record points at, or null when + /// the column is not a foreign key. + static String? targetTypeFor(String entityType, String field) => + _targetOverrides[entityType]?[field] ?? _defaultTargets[field]; + + /// Resolves every foreign key in [data]. Null and non-string values are + /// skipped, so a nullable reference that is unset produces no entry. + Future> resolve( + String entityType, + Map data, + ) async { + final references = []; + for (final entry in data.entries) { + final target = targetTypeFor(entityType, entry.key); + if (target == null) continue; + final id = entry.value; + if (id is! String || id.isEmpty) continue; + references.add(await _resolveOne(entry.key, target, id)); + } + return references; + } + + Future _resolveOne( + String field, + String targetType, + String recordId, + ) async { + final row = await _serializer.fetchRecord(targetType, recordId); + if (row == null) { + return ConflictReference( + field: field, + targetType: targetType, + recordId: recordId, + ); + } + return ConflictReference( + field: field, + targetType: targetType, + recordId: recordId, + name: _nameOf(row) ?? await _borrowedSiteName(row), + timestamp: _timestampOf(row), + ); + } + + /// An unnamed dive is displayed by its site everywhere else in the app, so + /// borrow that name here too. Exactly one hop: the site's own name is read + /// directly and never resolved further. + Future _borrowedSiteName(Map row) async { + final siteId = row['siteId']; + if (siteId is! String || siteId.isEmpty) return null; + final site = await _serializer.fetchRecord('diveSites', siteId); + return site == null ? null : _nameOf(site); + } + + static String? _nameOf(Map row) { + for (final field in _nameFields) { + final value = row[field]; + if (value is String && value.isNotEmpty) return value; + } + return null; + } + + static DateTime? _timestampOf(Map row) { + for (final field in _timestampFields) { + final value = row[field]; + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); + } + return null; + } +} diff --git a/lib/core/services/sync/sync_service.dart b/lib/core/services/sync/sync_service.dart index 0d9c4c983d..960ab00f22 100644 --- a/lib/core/services/sync/sync_service.dart +++ b/lib/core/services/sync/sync_service.dart @@ -12,6 +12,7 @@ import 'package:submersion/core/database/database.dart' import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/conflict_reference.dart'; import 'package:submersion/core/services/sync/changeset_log/base_json_stream_reader.dart'; import 'package:submersion/core/services/sync/changeset_log/base_parse_client.dart'; import 'package:submersion/core/services/sync/changeset_log/base_part_file_sink.dart'; @@ -157,6 +158,16 @@ class SyncConflict { final DateTime localModified; final DateTime remoteModified; + /// Foreign keys of [localData], resolved to the referenced rows' names and + /// dates. Junction entities carry nothing but ids, so this is the only thing + /// that lets the resolution dialog describe them (#1031). Empty when the + /// entity has no foreign keys, or when resolution could not run. + final List localReferences; + + /// The same for [remoteData]. Resolved separately because a junction row's + /// foreign keys are usually exactly what the two sides disagree about. + final List remoteReferences; + const SyncConflict({ required this.entityType, required this.recordId, @@ -164,6 +175,8 @@ class SyncConflict { required this.remoteData, required this.localModified, required this.remoteModified, + this.localReferences = const [], + this.remoteReferences = const [], }); String get displayName { @@ -365,6 +378,14 @@ class SyncService { localModified ?? DateTime.fromMillisecondsSinceEpoch(record.localUpdatedAt), remoteModified: remoteModified ?? DateTime.now(), + localReferences: await _resolveReferences( + record.entityType, + localData ?? {}, + ), + remoteReferences: await _resolveReferences( + record.entityType, + remoteData, + ), ), ); } catch (e) { @@ -379,6 +400,27 @@ class SyncService { return conflicts; } + /// Resolves a conflicting record's foreign keys for display. A lookup + /// failure degrades to an unresolved preview rather than dropping the whole + /// conflict, which would leave the user unable to resolve it at all. + Future> _resolveReferences( + String entityType, + Map data, + ) async { + if (data.isEmpty) return const []; + try { + return await ConflictReferenceResolver( + _serializer, + ).resolve(entityType, data); + } catch (e) { + _log.warning( + 'Could not resolve display references for $entityType', + error: e, + ); + return const []; + } + } + Map _parseConflictData(String json) { final parsed = jsonDecode(json); if (parsed is Map) { diff --git a/lib/features/settings/presentation/widgets/conflict_data_preview.dart b/lib/features/settings/presentation/widgets/conflict_data_preview.dart new file mode 100644 index 0000000000..1ef32bcfa5 --- /dev/null +++ b/lib/features/settings/presentation/widgets/conflict_data_preview.dart @@ -0,0 +1,250 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/core/services/sync/conflict_reference.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/data_quality/domain/entities/quality_finding.dart'; +// buildQualityUnitFormatters is the one place that binds the finding renderer +// to the diver's unit settings; it lives beside the inbox that first needed it. +import 'package:submersion/features/data_quality/presentation/pages/data_quality_inbox_page.dart' + show buildQualityUnitFormatters; +import 'package:submersion/features/data_quality/presentation/widgets/quality_finding_message.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/conflict_reference_labels.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// One labelled line of a conflict's data preview. +typedef ConflictPreviewRow = ({String label, String value}); + +/// Sync bookkeeping present on nearly every record. None of it helps a user +/// choose between two versions. +const _alwaysHidden = { + 'id', + 'hlc', + 'deviceId', + 'originDeviceId', + 'syncedAt', +}; + +/// Fields an entity renders some other way, and so must not repeat as raw +/// columns. Quality findings store facts, not prose: `detectorId` and `params` +/// become a localized sentence, so the raw values would only be noise. +const _entityHidden = >{ + 'qualityFindings': {'detectorId', 'detectorVersion', 'params', 'category'}, +}; + +/// Fields worth leading with when an entity has them, carried over from the +/// original preview so dives, sites and gear keep reading the way they did. +const _preferredFields = [ + 'name', + 'title', + 'description', + 'date', + 'location', + 'maxDepth', + 'duration', + 'notes', +]; + +/// A record's data preview: resolved references first, then the fields that +/// distinguish the two versions. +class ConflictDataPreview extends ConsumerWidget { + const ConflictDataPreview({ + super.key, + required this.entityType, + required this.data, + required this.references, + }); + + final String entityType; + final Map data; + final List references; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + if (data.isEmpty) { + return Text( + context.l10n.settings_conflict_noDataAvailable, + style: theme.textTheme.bodySmall?.copyWith(fontStyle: FontStyle.italic), + ); + } + + final rows = conflictPreviewRows( + l10n: context.l10n, + units: UnitFormatter(ref.watch(settingsProvider)), + findingFormatters: buildQualityUnitFormatters(ref), + entityType: entityType, + data: data, + references: references, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final row in rows) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '${row.label}:', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Text(row.value, style: theme.textTheme.bodySmall), + ), + ], + ), + ), + ], + ); + } +} + +/// Builds the preview lines for one side of a conflict. +/// +/// References come first because for a junction entity they are the only +/// real-world content the record has; the remaining columns follow, with +/// bookkeeping and already-rendered fields dropped. +List conflictPreviewRows({ + required AppLocalizations l10n, + required UnitFormatter units, + required QualityUnitFormatters findingFormatters, + required String entityType, + required Map data, + required List references, +}) { + final rows = [ + for (final reference in references) + ( + label: conflictReferenceLabel(l10n, reference), + value: conflictReferenceValue(l10n, units, reference), + ), + ]; + + final message = entityType == 'qualityFindings' + ? _findingMessage(l10n, findingFormatters, data) + : null; + if (message != null) { + rows.add(( + label: l10n.settings_conflict_ref_finding, + value: '${message.title}: ${message.detail}', + )); + } + + final hidden = { + ..._alwaysHidden, + ...?_entityHidden[entityType], + for (final reference in references) reference.field, + }; + for (final entry in _scalarFields(data, hidden).entries) { + rows.add(( + label: entry.key, + value: formatConflictScalar(l10n, units, entry.key, entry.value), + )); + } + return rows; +} + +/// Renders a value the way the app renders it elsewhere: epoch millis as a +/// date, a flag as yes/no. Everything else prints as stored. +String formatConflictScalar( + AppLocalizations l10n, + UnitFormatter units, + String key, + Object value, +) { + if (value is bool) { + return value ? l10n.common_action_yes : l10n.common_action_no; + } + if (value is int && _isTimestamp(key, value)) { + return units.formatDateTime( + DateTime.fromMillisecondsSinceEpoch(value), + l10n: l10n, + ); + } + return value.toString(); +} + +/// True for a column that stores a moment rather than a duration. Both the +/// name and the magnitude must agree: `bottomTime` and `runtime` are seconds, +/// so only values large enough to be Unix millis are treated as dates. +bool _isTimestamp(String key, int value) { + const millisFloor = 100000000000; // ~1973 in Unix millis + final named = + key.endsWith('At') || key.endsWith('Time') || key.endsWith('Date'); + return named && value >= millisFloor; +} + +Map _scalarFields( + Map data, + Set hidden, +) { + bool usable(String key) => !hidden.contains(key) && data[key] != null; + + final preferred = { + for (final key in _preferredFields) + if (data.containsKey(key) && usable(key)) key: data[key], + }; + if (preferred.isNotEmpty) return preferred; + + // Nothing recognizable: show the first few columns that survived the filter, + // which for a junction row is what is left after its foreign keys. + final fallback = {}; + for (final entry in data.entries) { + if (fallback.length >= 5) break; + if (usable(entry.key)) fallback[entry.key] = entry.value; + } + return fallback; +} + +/// Rebuilds a finding from its synced row so the data-quality renderer can +/// turn its numeric params into a localized sentence. +/// +/// Returns null when the row cannot be read as a finding (a category from a +/// newer schema, malformed params); the preview then falls back to showing the +/// raw columns, which is what it did before. +QualityFindingMessage? _findingMessage( + AppLocalizations l10n, + QualityUnitFormatters formatters, + Map data, +) { + final detectorId = data['detectorId']; + if (detectorId is! String || detectorId.isEmpty) return null; + try { + final finding = QualityFinding( + id: data['id'] as String? ?? '', + diveId: data['diveId'] as String? ?? '', + detectorId: detectorId, + detectorVersion: (data['detectorVersion'] as num?)?.toInt() ?? 0, + category: QualityCategory.values.byName(data['category'] as String), + severity: QualitySeverity.values.byName(data['severity'] as String), + status: QualityStatus.values.byName(data['status'] as String), + params: + jsonDecode(data['params'] as String? ?? '{}') as Map, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (data['createdAt'] as num?)?.toInt() ?? 0, + ), + updatedAt: DateTime.fromMillisecondsSinceEpoch( + (data['updatedAt'] as num?)?.toInt() ?? 0, + ), + ); + return buildFindingMessage(l10n, finding, formatters); + } on ArgumentError { + return null; + } on FormatException { + return null; + } on TypeError { + return null; + } +} diff --git a/lib/features/settings/presentation/widgets/conflict_reference_labels.dart b/lib/features/settings/presentation/widgets/conflict_reference_labels.dart new file mode 100644 index 0000000000..e4bd9487ca --- /dev/null +++ b/lib/features/settings/presentation/widgets/conflict_reference_labels.dart @@ -0,0 +1,132 @@ +import 'package:submersion/core/services/sync/conflict_reference.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Separator between the parts of a composed conflict title. +const String kConflictSummarySeparator = ' • '; + +/// What to call a resolved reference in the conflict dialog. +/// +/// Most columns are named after the entity they point at, so the target type +/// carries the label. The handful of columns that point at the same entity for +/// a different reason (a related dive, a course instructor) name themselves. +String conflictReferenceLabel( + AppLocalizations l10n, + ConflictReference reference, +) { + switch (reference.field) { + case 'relatedDiveId': + return l10n.settings_conflict_ref_relatedDive; + case 'linkedDiveId': + return l10n.settings_conflict_ref_linkedDive; + case 'sourceDiveId': + return l10n.settings_conflict_ref_sourceDive; + case 'instructorId': + return l10n.settings_conflict_ref_instructor; + case 'signerId': + return l10n.settings_conflict_ref_signer; + } + switch (reference.targetType) { + case 'dives': + return l10n.settings_conflict_ref_dive; + case 'diveSites': + return l10n.settings_conflict_ref_diveSite; + case 'tags': + return l10n.settings_conflict_ref_tag; + case 'diveTypes': + return l10n.settings_conflict_ref_diveType; + case 'divers': + return l10n.settings_conflict_ref_diver; + case 'buddies': + return l10n.settings_conflict_ref_buddy; + case 'equipment': + return l10n.settings_conflict_ref_equipment; + case 'equipmentSets': + return l10n.settings_conflict_ref_equipmentSet; + case 'cylinderConfigs': + return l10n.settings_conflict_ref_cylinderConfig; + case 'diveComputers': + return l10n.settings_conflict_ref_diveComputer; + case 'diveDataSources': + return l10n.settings_conflict_ref_dataSource; + case 'diveTanks': + return l10n.settings_conflict_ref_tank; + case 'divePlanTanks': + return l10n.settings_conflict_ref_plannedTank; + case 'divePlans': + return l10n.settings_conflict_ref_divePlan; + case 'trips': + return l10n.settings_conflict_ref_trip; + case 'diveCenters': + return l10n.settings_conflict_ref_diveCenter; + case 'courses': + return l10n.settings_conflict_ref_course; + case 'certifications': + return l10n.settings_conflict_ref_certification; + case 'courseRequirements': + return l10n.settings_conflict_ref_courseRequirement; + case 'serviceKinds': + return l10n.settings_conflict_ref_serviceKind; + case 'species': + return l10n.settings_conflict_ref_species; + case 'sightings': + return l10n.settings_conflict_ref_sighting; + case 'media': + return l10n.settings_conflict_ref_media; + case 'mediaSubscriptions': + return l10n.settings_conflict_ref_mediaSubscription; + case 'connectedAccounts': + return l10n.settings_conflict_ref_connectedAccount; + case 'preDiveSessions': + return l10n.settings_conflict_ref_preDiveSession; + case 'checklistTemplates': + return l10n.settings_conflict_ref_checklistTemplate; + case 'preDiveChecklistTemplates': + return l10n.settings_conflict_ref_preDiveChecklistTemplate; + default: + return humanizeEntityType(reference.targetType); + } +} + +/// The referenced record in one line: its name, its date, or both. A record +/// that is not in the local library says so rather than rendering blank. +String conflictReferenceValue( + AppLocalizations l10n, + UnitFormatter units, + ConflictReference reference, +) { + if (reference.isMissing) return l10n.settings_conflict_ref_missing; + final date = reference.timestamp == null + ? null + : units.formatDate(reference.timestamp); + final name = reference.name; + if (name != null && date != null) { + return l10n.settings_conflict_ref_named(name, date); + } + return name ?? date!; +} + +/// Names the conflicting record from the records it points at, for junction +/// and relation entities that have no name of their own. Null when nothing +/// resolved, so the caller can fall back to the entity type and id. +String? conflictReferenceSummary(List references) { + final names = [ + for (final reference in references) + if (reference.name != null) reference.name!, + ]; + if (names.isEmpty) return null; + return names.take(3).join(kConflictSummarySeparator); +} + +/// Turns a sync entity type into readable words: `diveTags` -> `Dive Tags`. +/// Used for the entity types that have no dedicated label of their own. +String humanizeEntityType(String entityType) { + final spaced = entityType + .replaceAll('_', ' ') + .replaceAllMapped(RegExp(r'(?<=[a-z0-9])(?=[A-Z])'), (_) => ' '); + return spaced + .split(' ') + .where((word) => word.isNotEmpty) + .map((word) => '${word[0].toUpperCase()}${word.substring(1)}') + .join(' '); +} diff --git a/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart b/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart index 3cae01d953..fbd6f8ac82 100644 --- a/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart +++ b/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart @@ -3,6 +3,8 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/services/sync/sync_service.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/conflict_data_preview.dart'; +import 'package:submersion/features/settings/presentation/widgets/conflict_reference_labels.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// Dialog for resolving sync conflicts between local and remote data @@ -153,11 +155,11 @@ class _ConflictResolutionDialogState crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - conflict.displayName, + _conflictTitle(conflict), style: theme.textTheme.titleMedium, ), Text( - _formatEntityType(conflict.entityType), + humanizeEntityType(conflict.entityType), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -197,7 +199,11 @@ class _ConflictResolutionDialogState ], ), const SizedBox(height: 8), - _buildDataPreview(context, conflict.localData), + ConflictDataPreview( + entityType: conflict.entityType, + data: conflict.localData, + references: conflict.localReferences, + ), ], ), ), @@ -230,7 +236,11 @@ class _ConflictResolutionDialogState ], ), const SizedBox(height: 8), - _buildDataPreview(context, conflict.remoteData), + ConflictDataPreview( + entityType: conflict.entityType, + data: conflict.remoteData, + references: conflict.remoteReferences, + ), ], ), ), @@ -239,79 +249,17 @@ class _ConflictResolutionDialogState ); } - Widget _buildDataPreview(BuildContext context, Map data) { - if (data.isEmpty) { - return Text( - context.l10n.settings_conflict_noDataAvailable, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontStyle: FontStyle.italic), - ); - } - - // Show key fields from the data - final displayFields = _getDisplayFields(data); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: displayFields.entries.map((entry) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - '${entry.key}:', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Expanded( - child: Text( - entry.value.toString(), - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - ); - }).toList(), - ); - } - - Map _getDisplayFields(Map data) { - // Filter to show only important fields - const importantKeys = [ - 'name', - 'title', - 'description', - 'date', - 'location', - 'maxDepth', - 'duration', - 'notes', - ]; - - final result = {}; - for (final key in importantKeys) { - if (data.containsKey(key) && data[key] != null) { - result[key] = data[key]; - } - } - - // If no important fields found, show first few fields - if (result.isEmpty) { - final entries = data.entries.take(5); - for (final entry in entries) { - if (entry.value != null) { - result[entry.key] = entry.value; - } - } - } - - return result; + /// Names the record a user is being asked about. Junction and relation + /// entities have no name of their own, so they are named by the records they + /// point at; only a record that resolved to nothing falls back to its id. + String _conflictTitle(SyncConflict conflict) { + final own = + conflict.localData['name'] as String? ?? + conflict.localData['title'] as String?; + if (own != null && own.isNotEmpty) return own; + return conflictReferenceSummary(conflict.localReferences) ?? + conflictReferenceSummary(conflict.remoteReferences) ?? + conflict.displayName; } Widget _buildResolutionOptions(BuildContext context, SyncConflict conflict) { @@ -465,19 +413,6 @@ class _ConflictResolutionDialogState } } - String _formatEntityType(String entityType) { - // Convert snake_case to Title Case - return entityType - .replaceAll('_', ' ') - .split(' ') - .map( - (word) => word.isNotEmpty - ? '${word[0].toUpperCase()}${word.substring(1)}' - : '', - ) - .join(' '); - } - String _formatDateTime(DateTime dateTime) { final now = DateTime.now(); final difference = now.difference(dateTime); diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..43fe3a1c0b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -4600,6 +4600,42 @@ "settings_conflict_noConflicts_title": "لا توجد تعارضات", "settings_conflict_noDataAvailable": "لا توجد بيانات متاحة", "settings_conflict_previous_tooltip": "التعارض السابق", + "settings_conflict_ref_buddy": "رفيق الغوص", + "settings_conflict_ref_certification": "الشهادة", + "settings_conflict_ref_checklistTemplate": "قالب قائمة التحقق", + "settings_conflict_ref_connectedAccount": "الحساب المتصل", + "settings_conflict_ref_course": "الدورة", + "settings_conflict_ref_courseRequirement": "متطلب الدورة", + "settings_conflict_ref_cylinderConfig": "إعداد الأسطوانات", + "settings_conflict_ref_dataSource": "مصدر البيانات", + "settings_conflict_ref_dive": "الغوصة", + "settings_conflict_ref_diveCenter": "مركز الغوص", + "settings_conflict_ref_diveComputer": "حاسوب الغوص", + "settings_conflict_ref_divePlan": "خطة الغوص", + "settings_conflict_ref_diveSite": "موقع الغوص", + "settings_conflict_ref_diveType": "نوع الغوصة", + "settings_conflict_ref_diver": "الغواص", + "settings_conflict_ref_equipment": "المعدات", + "settings_conflict_ref_equipmentSet": "طقم المعدات", + "settings_conflict_ref_finding": "الملاحظة", + "settings_conflict_ref_instructor": "المدرب", + "settings_conflict_ref_linkedDive": "الغوصة المرتبطة", + "settings_conflict_ref_media": "الوسائط", + "settings_conflict_ref_mediaSubscription": "اشتراك الوسائط", + "settings_conflict_ref_missing": "لم تعد موجودة في هذه المكتبة", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "الأسطوانة المخططة", + "settings_conflict_ref_preDiveChecklistTemplate": "قالب قائمة التحقق قبل الغوص", + "settings_conflict_ref_preDiveSession": "قائمة التحقق قبل الغوص", + "settings_conflict_ref_relatedDive": "الغوصة ذات الصلة", + "settings_conflict_ref_serviceKind": "نوع الصيانة", + "settings_conflict_ref_sighting": "المشاهدة", + "settings_conflict_ref_signer": "وقّع بواسطة", + "settings_conflict_ref_sourceDive": "الغوصة المصدر", + "settings_conflict_ref_species": "الأنواع", + "settings_conflict_ref_tag": "الوسم", + "settings_conflict_ref_tank": "الأسطوانة", + "settings_conflict_ref_trip": "الرحلة", "settings_conflict_remoteVersion": "النسخة البعيدة", "settings_conflict_resolved": "تم حل {count, plural, =1{تعارض واحد} other{{count} تعارضات}}", "settings_conflict_title": "حل التعارضات", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..2c57df613e 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -4600,6 +4600,42 @@ "settings_conflict_noConflicts_title": "Keine Konflikte", "settings_conflict_noDataAvailable": "Keine Daten verfügbar", "settings_conflict_previous_tooltip": "Vorheriger Konflikt", + "settings_conflict_ref_buddy": "Tauchpartner", + "settings_conflict_ref_certification": "Zertifizierung", + "settings_conflict_ref_checklistTemplate": "Checklisten-Vorlage", + "settings_conflict_ref_connectedAccount": "Verbundenes Konto", + "settings_conflict_ref_course": "Kurs", + "settings_conflict_ref_courseRequirement": "Kursanforderung", + "settings_conflict_ref_cylinderConfig": "Flaschenkonfiguration", + "settings_conflict_ref_dataSource": "Datenquelle", + "settings_conflict_ref_dive": "Tauchgang", + "settings_conflict_ref_diveCenter": "Tauchbasis", + "settings_conflict_ref_diveComputer": "Tauchcomputer", + "settings_conflict_ref_divePlan": "Tauchplan", + "settings_conflict_ref_diveSite": "Tauchplatz", + "settings_conflict_ref_diveType": "Tauchgangart", + "settings_conflict_ref_diver": "Taucher", + "settings_conflict_ref_equipment": "Ausrüstung", + "settings_conflict_ref_equipmentSet": "Ausrüstungsset", + "settings_conflict_ref_finding": "Befund", + "settings_conflict_ref_instructor": "Instructor", + "settings_conflict_ref_linkedDive": "Verknüpfter Tauchgang", + "settings_conflict_ref_media": "Medien", + "settings_conflict_ref_mediaSubscription": "Medien-Abonnement", + "settings_conflict_ref_missing": "Nicht mehr in dieser Bibliothek", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Geplante Flasche", + "settings_conflict_ref_preDiveChecklistTemplate": "Vorlage für Checkliste vor dem Tauchgang", + "settings_conflict_ref_preDiveSession": "Checkliste vor dem Tauchgang", + "settings_conflict_ref_relatedDive": "Zugehöriger Tauchgang", + "settings_conflict_ref_serviceKind": "Wartungsart", + "settings_conflict_ref_sighting": "Sichtung", + "settings_conflict_ref_signer": "Unterschrieben von", + "settings_conflict_ref_sourceDive": "Quell-Tauchgang", + "settings_conflict_ref_species": "Art", + "settings_conflict_ref_tag": "Tag", + "settings_conflict_ref_tank": "Flasche", + "settings_conflict_ref_trip": "Reise", "settings_conflict_remoteVersion": "Remote-Version", "settings_conflict_resolved": "{count, plural, =1{1 Konflikt} other{{count} Konflikte}} gelöst", "settings_conflict_title": "Konflikte lösen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..3f9d9b9640 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -8963,6 +8963,42 @@ "settings_conflict_noConflicts_title": "No Conflicts", "settings_conflict_noDataAvailable": "No data available", "settings_conflict_previous_tooltip": "Previous conflict", + "settings_conflict_ref_buddy": "Buddy", + "settings_conflict_ref_certification": "Certification", + "settings_conflict_ref_checklistTemplate": "Checklist template", + "settings_conflict_ref_connectedAccount": "Connected account", + "settings_conflict_ref_course": "Course", + "settings_conflict_ref_courseRequirement": "Course requirement", + "settings_conflict_ref_cylinderConfig": "Cylinder configuration", + "settings_conflict_ref_dataSource": "Data source", + "settings_conflict_ref_dive": "Dive", + "settings_conflict_ref_diveCenter": "Dive center", + "settings_conflict_ref_diveComputer": "Dive computer", + "settings_conflict_ref_divePlan": "Dive plan", + "settings_conflict_ref_diveSite": "Dive site", + "settings_conflict_ref_diveType": "Dive type", + "settings_conflict_ref_diver": "Diver", + "settings_conflict_ref_equipment": "Equipment", + "settings_conflict_ref_equipmentSet": "Equipment set", + "settings_conflict_ref_finding": "Finding", + "settings_conflict_ref_instructor": "Instructor", + "settings_conflict_ref_linkedDive": "Linked dive", + "settings_conflict_ref_media": "Media", + "settings_conflict_ref_mediaSubscription": "Media subscription", + "settings_conflict_ref_missing": "No longer in this library", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Planned tank", + "settings_conflict_ref_preDiveChecklistTemplate": "Pre-dive checklist template", + "settings_conflict_ref_preDiveSession": "Pre-dive checklist run", + "settings_conflict_ref_relatedDive": "Related dive", + "settings_conflict_ref_serviceKind": "Service type", + "settings_conflict_ref_sighting": "Sighting", + "settings_conflict_ref_signer": "Signed by", + "settings_conflict_ref_sourceDive": "Source dive", + "settings_conflict_ref_species": "Species", + "settings_conflict_ref_tag": "Tag", + "settings_conflict_ref_tank": "Tank", + "settings_conflict_ref_trip": "Trip", "settings_conflict_remoteVersion": "Remote Version", "settings_conflict_resolved": "Resolved {count, plural, =1{1 conflict} other{{count} conflicts}}", "settings_conflict_title": "Resolve Conflicts", @@ -9553,6 +9589,16 @@ } } }, + "@settings_conflict_ref_named": { + "placeholders": { + "name": { + "type": "Object" + }, + "date": { + "type": "Object" + } + } + }, "@settings_conflict_errorLoading": { "placeholders": { "error": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..da4e196580 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -4600,6 +4600,42 @@ "settings_conflict_noConflicts_title": "Sin conflictos", "settings_conflict_noDataAvailable": "No hay datos disponibles", "settings_conflict_previous_tooltip": "Conflicto anterior", + "settings_conflict_ref_buddy": "Compañero", + "settings_conflict_ref_certification": "Certificacion", + "settings_conflict_ref_checklistTemplate": "Plantilla de lista de verificacion", + "settings_conflict_ref_connectedAccount": "Cuenta conectada", + "settings_conflict_ref_course": "Curso", + "settings_conflict_ref_courseRequirement": "Requisito del curso", + "settings_conflict_ref_cylinderConfig": "Configuracion de botellas", + "settings_conflict_ref_dataSource": "Fuente de datos", + "settings_conflict_ref_dive": "Inmersion", + "settings_conflict_ref_diveCenter": "Centro de buceo", + "settings_conflict_ref_diveComputer": "Ordenador de buceo", + "settings_conflict_ref_divePlan": "Plan de buceo", + "settings_conflict_ref_diveSite": "Punto de buceo", + "settings_conflict_ref_diveType": "Tipo de inmersion", + "settings_conflict_ref_diver": "Buceador", + "settings_conflict_ref_equipment": "Equipo", + "settings_conflict_ref_equipmentSet": "Conjunto de equipo", + "settings_conflict_ref_finding": "Hallazgo", + "settings_conflict_ref_instructor": "Instructor", + "settings_conflict_ref_linkedDive": "Inmersion vinculada", + "settings_conflict_ref_media": "Multimedia", + "settings_conflict_ref_mediaSubscription": "Suscripcion multimedia", + "settings_conflict_ref_missing": "Ya no esta en esta biblioteca", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Tanque planificado", + "settings_conflict_ref_preDiveChecklistTemplate": "Plantilla de lista previa a la inmersion", + "settings_conflict_ref_preDiveSession": "Lista previa a la inmersion", + "settings_conflict_ref_relatedDive": "Inmersion relacionada", + "settings_conflict_ref_serviceKind": "Tipo de servicio", + "settings_conflict_ref_sighting": "Avistamiento", + "settings_conflict_ref_signer": "Firmado por", + "settings_conflict_ref_sourceDive": "Inmersion de origen", + "settings_conflict_ref_species": "Especies", + "settings_conflict_ref_tag": "Etiqueta", + "settings_conflict_ref_tank": "Tanque", + "settings_conflict_ref_trip": "Viaje", "settings_conflict_remoteVersion": "Version remota", "settings_conflict_resolved": "Se resolvieron {count, plural, =1{1 conflicto} other{{count} conflictos}}", "settings_conflict_title": "Resolver conflictos", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..3bf824ad10 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -4527,6 +4527,42 @@ "settings_conflict_noConflicts_title": "Aucun conflit", "settings_conflict_noDataAvailable": "Aucune donnee disponible", "settings_conflict_previous_tooltip": "Conflit precedent", + "settings_conflict_ref_buddy": "Binome", + "settings_conflict_ref_certification": "Certification", + "settings_conflict_ref_checklistTemplate": "Modele de liste de controle", + "settings_conflict_ref_connectedAccount": "Compte connecte", + "settings_conflict_ref_course": "Cours", + "settings_conflict_ref_courseRequirement": "Exigence du cours", + "settings_conflict_ref_cylinderConfig": "Configuration de blocs", + "settings_conflict_ref_dataSource": "Source de donnees", + "settings_conflict_ref_dive": "Plongee", + "settings_conflict_ref_diveCenter": "Centre de plongee", + "settings_conflict_ref_diveComputer": "Ordinateur de plongee", + "settings_conflict_ref_divePlan": "Plan de plongee", + "settings_conflict_ref_diveSite": "Site de plongee", + "settings_conflict_ref_diveType": "Type de plongee", + "settings_conflict_ref_diver": "Plongeur", + "settings_conflict_ref_equipment": "Equipement", + "settings_conflict_ref_equipmentSet": "Ensemble d'equipement", + "settings_conflict_ref_finding": "Anomalie", + "settings_conflict_ref_instructor": "Moniteur", + "settings_conflict_ref_linkedDive": "Plongee liee", + "settings_conflict_ref_media": "Medias", + "settings_conflict_ref_mediaSubscription": "Abonnement media", + "settings_conflict_ref_missing": "N'est plus dans cette bibliotheque", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Bloc planifie", + "settings_conflict_ref_preDiveChecklistTemplate": "Modele de liste avant plongee", + "settings_conflict_ref_preDiveSession": "Liste avant plongee", + "settings_conflict_ref_relatedDive": "Plongee associee", + "settings_conflict_ref_serviceKind": "Type de revision", + "settings_conflict_ref_sighting": "Observation", + "settings_conflict_ref_signer": "Signe par", + "settings_conflict_ref_sourceDive": "Plongee source", + "settings_conflict_ref_species": "Especes", + "settings_conflict_ref_tag": "Etiquette", + "settings_conflict_ref_tank": "Bloc", + "settings_conflict_ref_trip": "Voyage", "settings_conflict_remoteVersion": "Version distante", "settings_conflict_resolved": "{count, plural, =1{1 conflit resolu} other{{count} conflits resolus}}", "settings_conflict_title": "Resoudre les conflits", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..3a7736cdae 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -4604,6 +4604,42 @@ "settings_conflict_noConflicts_title": "אין התנגשויות", "settings_conflict_noDataAvailable": "אין נתונים זמינים", "settings_conflict_previous_tooltip": "ההתנגשות הקודמת", + "settings_conflict_ref_buddy": "שותף", + "settings_conflict_ref_certification": "הסמכה", + "settings_conflict_ref_checklistTemplate": "תבנית רשימת משימות", + "settings_conflict_ref_connectedAccount": "חשבון מחובר", + "settings_conflict_ref_course": "קורס", + "settings_conflict_ref_courseRequirement": "דרישת קורס", + "settings_conflict_ref_cylinderConfig": "תצורת בלונים", + "settings_conflict_ref_dataSource": "מקור נתונים", + "settings_conflict_ref_dive": "צלילה", + "settings_conflict_ref_diveCenter": "מועדון צלילה", + "settings_conflict_ref_diveComputer": "מחשב צלילה", + "settings_conflict_ref_divePlan": "תוכנית צלילה", + "settings_conflict_ref_diveSite": "אתר צלילה", + "settings_conflict_ref_diveType": "סוג צלילה", + "settings_conflict_ref_diver": "צולל", + "settings_conflict_ref_equipment": "ציוד", + "settings_conflict_ref_equipmentSet": "סט ציוד", + "settings_conflict_ref_finding": "ממצא", + "settings_conflict_ref_instructor": "מדריך", + "settings_conflict_ref_linkedDive": "צלילה מקושרת", + "settings_conflict_ref_media": "מדיה", + "settings_conflict_ref_mediaSubscription": "מנוי מדיה", + "settings_conflict_ref_missing": "כבר לא בספרייה הזו", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "בלון מתוכנן", + "settings_conflict_ref_preDiveChecklistTemplate": "תבנית רשימת בדיקות לפני צלילה", + "settings_conflict_ref_preDiveSession": "רשימת בדיקות לפני צלילה", + "settings_conflict_ref_relatedDive": "צלילה קשורה", + "settings_conflict_ref_serviceKind": "סוג טיפול", + "settings_conflict_ref_sighting": "תצפית", + "settings_conflict_ref_signer": "נחתם על ידי", + "settings_conflict_ref_sourceDive": "צלילת מקור", + "settings_conflict_ref_species": "מינים", + "settings_conflict_ref_tag": "תגית", + "settings_conflict_ref_tank": "בלון", + "settings_conflict_ref_trip": "טיול", "settings_conflict_remoteVersion": "גרסה מרוחקת", "settings_conflict_resolved": "נפתרו {count, plural, =1{התנגשות אחת} other{{count} התנגשויות}}", "settings_conflict_title": "פתרון התנגשויות", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..daae6e3a2f 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -4527,6 +4527,42 @@ "settings_conflict_noConflicts_title": "Nincsenek ütközesek", "settings_conflict_noDataAvailable": "Nincs elerheto adat", "settings_conflict_previous_tooltip": "Elozo ütközes", + "settings_conflict_ref_buddy": "Buvartars", + "settings_conflict_ref_certification": "Kepesites", + "settings_conflict_ref_checklistTemplate": "Ellenorzolista sablon", + "settings_conflict_ref_connectedAccount": "Csatlakoztatott fiok", + "settings_conflict_ref_course": "Tanfolyam", + "settings_conflict_ref_courseRequirement": "Tanfolyami kovetelmeny", + "settings_conflict_ref_cylinderConfig": "Palackkonfiguracio", + "settings_conflict_ref_dataSource": "Adatforras", + "settings_conflict_ref_dive": "Merules", + "settings_conflict_ref_diveCenter": "Merulocentrum", + "settings_conflict_ref_diveComputer": "Merulesszamitogep", + "settings_conflict_ref_divePlan": "Merulesi terv", + "settings_conflict_ref_diveSite": "Merulohely", + "settings_conflict_ref_diveType": "Merules tipusa", + "settings_conflict_ref_diver": "Merulo", + "settings_conflict_ref_equipment": "Felszereles", + "settings_conflict_ref_equipmentSet": "Felszereleskeszlet", + "settings_conflict_ref_finding": "Eszrevetel", + "settings_conflict_ref_instructor": "Oktato", + "settings_conflict_ref_linkedDive": "Kapcsolt merules", + "settings_conflict_ref_media": "Media", + "settings_conflict_ref_mediaSubscription": "Media-elofizetes", + "settings_conflict_ref_missing": "Mar nincs ebben a konyvtarban", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Tervezett palack", + "settings_conflict_ref_preDiveChecklistTemplate": "Merules elotti ellenorzolista sablon", + "settings_conflict_ref_preDiveSession": "Merules elotti ellenorzolista", + "settings_conflict_ref_relatedDive": "Kapcsolodo merules", + "settings_conflict_ref_serviceKind": "Szerviz tipusa", + "settings_conflict_ref_sighting": "Eszleles", + "settings_conflict_ref_signer": "Alairta", + "settings_conflict_ref_sourceDive": "Forras merules", + "settings_conflict_ref_species": "Fajok", + "settings_conflict_ref_tag": "Cimke", + "settings_conflict_ref_tank": "Palack", + "settings_conflict_ref_trip": "Utazas", "settings_conflict_remoteVersion": "Tavoli valtozat", "settings_conflict_resolved": "{count, plural, =1{1 ütközes} other{{count} ütközes}} feloldva", "settings_conflict_title": "Ütközesek feloldasa", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..9fd7e32153 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -4527,6 +4527,42 @@ "settings_conflict_noConflicts_title": "Nessun conflitto", "settings_conflict_noDataAvailable": "Nessun dato disponibile", "settings_conflict_previous_tooltip": "Conflitto precedente", + "settings_conflict_ref_buddy": "Compagno", + "settings_conflict_ref_certification": "Certificazione", + "settings_conflict_ref_checklistTemplate": "Modello di lista di controllo", + "settings_conflict_ref_connectedAccount": "Account collegato", + "settings_conflict_ref_course": "Corso", + "settings_conflict_ref_courseRequirement": "Requisito del corso", + "settings_conflict_ref_cylinderConfig": "Configurazione bombole", + "settings_conflict_ref_dataSource": "Origine dati", + "settings_conflict_ref_dive": "Immersione", + "settings_conflict_ref_diveCenter": "Centro immersioni", + "settings_conflict_ref_diveComputer": "Computer subacqueo", + "settings_conflict_ref_divePlan": "Piano di immersione", + "settings_conflict_ref_diveSite": "Sito di immersione", + "settings_conflict_ref_diveType": "Tipo di immersione", + "settings_conflict_ref_diver": "Subacqueo", + "settings_conflict_ref_equipment": "Attrezzatura", + "settings_conflict_ref_equipmentSet": "Set di attrezzatura", + "settings_conflict_ref_finding": "Rilievo", + "settings_conflict_ref_instructor": "Istruttore", + "settings_conflict_ref_linkedDive": "Immersione collegata", + "settings_conflict_ref_media": "Media", + "settings_conflict_ref_mediaSubscription": "Abbonamento media", + "settings_conflict_ref_missing": "Non è più in questa libreria", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Bombola pianificata", + "settings_conflict_ref_preDiveChecklistTemplate": "Modello di lista pre-immersione", + "settings_conflict_ref_preDiveSession": "Lista pre-immersione", + "settings_conflict_ref_relatedDive": "Immersione correlata", + "settings_conflict_ref_serviceKind": "Tipo di revisione", + "settings_conflict_ref_sighting": "Avvistamento", + "settings_conflict_ref_signer": "Firmato da", + "settings_conflict_ref_sourceDive": "Immersione di origine", + "settings_conflict_ref_species": "Specie", + "settings_conflict_ref_tag": "Tag", + "settings_conflict_ref_tank": "Bombola", + "settings_conflict_ref_trip": "Viaggio", "settings_conflict_remoteVersion": "Versione remota", "settings_conflict_resolved": "{count, plural, =1{1 conflitto risolto} other{{count} conflitti risolti}}", "settings_conflict_title": "Risolvi conflitti", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..c00fdd430f 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -25549,6 +25549,222 @@ abstract class AppLocalizations { /// **'Previous conflict'** String get settings_conflict_previous_tooltip; + /// No description provided for @settings_conflict_ref_buddy. + /// + /// In en, this message translates to: + /// **'Buddy'** + String get settings_conflict_ref_buddy; + + /// No description provided for @settings_conflict_ref_certification. + /// + /// In en, this message translates to: + /// **'Certification'** + String get settings_conflict_ref_certification; + + /// No description provided for @settings_conflict_ref_checklistTemplate. + /// + /// In en, this message translates to: + /// **'Checklist template'** + String get settings_conflict_ref_checklistTemplate; + + /// No description provided for @settings_conflict_ref_connectedAccount. + /// + /// In en, this message translates to: + /// **'Connected account'** + String get settings_conflict_ref_connectedAccount; + + /// No description provided for @settings_conflict_ref_course. + /// + /// In en, this message translates to: + /// **'Course'** + String get settings_conflict_ref_course; + + /// No description provided for @settings_conflict_ref_courseRequirement. + /// + /// In en, this message translates to: + /// **'Course requirement'** + String get settings_conflict_ref_courseRequirement; + + /// No description provided for @settings_conflict_ref_cylinderConfig. + /// + /// In en, this message translates to: + /// **'Cylinder configuration'** + String get settings_conflict_ref_cylinderConfig; + + /// No description provided for @settings_conflict_ref_dataSource. + /// + /// In en, this message translates to: + /// **'Data source'** + String get settings_conflict_ref_dataSource; + + /// No description provided for @settings_conflict_ref_dive. + /// + /// In en, this message translates to: + /// **'Dive'** + String get settings_conflict_ref_dive; + + /// No description provided for @settings_conflict_ref_diveCenter. + /// + /// In en, this message translates to: + /// **'Dive center'** + String get settings_conflict_ref_diveCenter; + + /// No description provided for @settings_conflict_ref_diveComputer. + /// + /// In en, this message translates to: + /// **'Dive computer'** + String get settings_conflict_ref_diveComputer; + + /// No description provided for @settings_conflict_ref_divePlan. + /// + /// In en, this message translates to: + /// **'Dive plan'** + String get settings_conflict_ref_divePlan; + + /// No description provided for @settings_conflict_ref_diveSite. + /// + /// In en, this message translates to: + /// **'Dive site'** + String get settings_conflict_ref_diveSite; + + /// No description provided for @settings_conflict_ref_diveType. + /// + /// In en, this message translates to: + /// **'Dive type'** + String get settings_conflict_ref_diveType; + + /// No description provided for @settings_conflict_ref_diver. + /// + /// In en, this message translates to: + /// **'Diver'** + String get settings_conflict_ref_diver; + + /// No description provided for @settings_conflict_ref_equipment. + /// + /// In en, this message translates to: + /// **'Equipment'** + String get settings_conflict_ref_equipment; + + /// No description provided for @settings_conflict_ref_equipmentSet. + /// + /// In en, this message translates to: + /// **'Equipment set'** + String get settings_conflict_ref_equipmentSet; + + /// No description provided for @settings_conflict_ref_finding. + /// + /// In en, this message translates to: + /// **'Finding'** + String get settings_conflict_ref_finding; + + /// No description provided for @settings_conflict_ref_instructor. + /// + /// In en, this message translates to: + /// **'Instructor'** + String get settings_conflict_ref_instructor; + + /// No description provided for @settings_conflict_ref_linkedDive. + /// + /// In en, this message translates to: + /// **'Linked dive'** + String get settings_conflict_ref_linkedDive; + + /// No description provided for @settings_conflict_ref_media. + /// + /// In en, this message translates to: + /// **'Media'** + String get settings_conflict_ref_media; + + /// No description provided for @settings_conflict_ref_mediaSubscription. + /// + /// In en, this message translates to: + /// **'Media subscription'** + String get settings_conflict_ref_mediaSubscription; + + /// No description provided for @settings_conflict_ref_missing. + /// + /// In en, this message translates to: + /// **'No longer in this library'** + String get settings_conflict_ref_missing; + + /// No description provided for @settings_conflict_ref_named. + /// + /// In en, this message translates to: + /// **'{name} ({date})'** + String settings_conflict_ref_named(Object name, Object date); + + /// No description provided for @settings_conflict_ref_plannedTank. + /// + /// In en, this message translates to: + /// **'Planned tank'** + String get settings_conflict_ref_plannedTank; + + /// No description provided for @settings_conflict_ref_preDiveChecklistTemplate. + /// + /// In en, this message translates to: + /// **'Pre-dive checklist template'** + String get settings_conflict_ref_preDiveChecklistTemplate; + + /// No description provided for @settings_conflict_ref_preDiveSession. + /// + /// In en, this message translates to: + /// **'Pre-dive checklist run'** + String get settings_conflict_ref_preDiveSession; + + /// No description provided for @settings_conflict_ref_relatedDive. + /// + /// In en, this message translates to: + /// **'Related dive'** + String get settings_conflict_ref_relatedDive; + + /// No description provided for @settings_conflict_ref_serviceKind. + /// + /// In en, this message translates to: + /// **'Service type'** + String get settings_conflict_ref_serviceKind; + + /// No description provided for @settings_conflict_ref_sighting. + /// + /// In en, this message translates to: + /// **'Sighting'** + String get settings_conflict_ref_sighting; + + /// No description provided for @settings_conflict_ref_signer. + /// + /// In en, this message translates to: + /// **'Signed by'** + String get settings_conflict_ref_signer; + + /// No description provided for @settings_conflict_ref_sourceDive. + /// + /// In en, this message translates to: + /// **'Source dive'** + String get settings_conflict_ref_sourceDive; + + /// No description provided for @settings_conflict_ref_species. + /// + /// In en, this message translates to: + /// **'Species'** + String get settings_conflict_ref_species; + + /// No description provided for @settings_conflict_ref_tag. + /// + /// In en, this message translates to: + /// **'Tag'** + String get settings_conflict_ref_tag; + + /// No description provided for @settings_conflict_ref_tank. + /// + /// In en, this message translates to: + /// **'Tank'** + String get settings_conflict_ref_tank; + + /// No description provided for @settings_conflict_ref_trip. + /// + /// In en, this message translates to: + /// **'Trip'** + String get settings_conflict_ref_trip; + /// No description provided for @settings_conflict_remoteVersion. /// /// 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..6ab3b0b897 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -14974,6 +14974,117 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'التعارض السابق'; + @override + String get settings_conflict_ref_buddy => 'رفيق الغوص'; + + @override + String get settings_conflict_ref_certification => 'الشهادة'; + + @override + String get settings_conflict_ref_checklistTemplate => 'قالب قائمة التحقق'; + + @override + String get settings_conflict_ref_connectedAccount => 'الحساب المتصل'; + + @override + String get settings_conflict_ref_course => 'الدورة'; + + @override + String get settings_conflict_ref_courseRequirement => 'متطلب الدورة'; + + @override + String get settings_conflict_ref_cylinderConfig => 'إعداد الأسطوانات'; + + @override + String get settings_conflict_ref_dataSource => 'مصدر البيانات'; + + @override + String get settings_conflict_ref_dive => 'الغوصة'; + + @override + String get settings_conflict_ref_diveCenter => 'مركز الغوص'; + + @override + String get settings_conflict_ref_diveComputer => 'حاسوب الغوص'; + + @override + String get settings_conflict_ref_divePlan => 'خطة الغوص'; + + @override + String get settings_conflict_ref_diveSite => 'موقع الغوص'; + + @override + String get settings_conflict_ref_diveType => 'نوع الغوصة'; + + @override + String get settings_conflict_ref_diver => 'الغواص'; + + @override + String get settings_conflict_ref_equipment => 'المعدات'; + + @override + String get settings_conflict_ref_equipmentSet => 'طقم المعدات'; + + @override + String get settings_conflict_ref_finding => 'الملاحظة'; + + @override + String get settings_conflict_ref_instructor => 'المدرب'; + + @override + String get settings_conflict_ref_linkedDive => 'الغوصة المرتبطة'; + + @override + String get settings_conflict_ref_media => 'الوسائط'; + + @override + String get settings_conflict_ref_mediaSubscription => 'اشتراك الوسائط'; + + @override + String get settings_conflict_ref_missing => 'لم تعد موجودة في هذه المكتبة'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'الأسطوانة المخططة'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'قالب قائمة التحقق قبل الغوص'; + + @override + String get settings_conflict_ref_preDiveSession => 'قائمة التحقق قبل الغوص'; + + @override + String get settings_conflict_ref_relatedDive => 'الغوصة ذات الصلة'; + + @override + String get settings_conflict_ref_serviceKind => 'نوع الصيانة'; + + @override + String get settings_conflict_ref_sighting => 'المشاهدة'; + + @override + String get settings_conflict_ref_signer => 'وقّع بواسطة'; + + @override + String get settings_conflict_ref_sourceDive => 'الغوصة المصدر'; + + @override + String get settings_conflict_ref_species => 'الأنواع'; + + @override + String get settings_conflict_ref_tag => 'الوسم'; + + @override + String get settings_conflict_ref_tank => 'الأسطوانة'; + + @override + String get settings_conflict_ref_trip => 'الرحلة'; + @override String get settings_conflict_remoteVersion => 'النسخة البعيدة'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..f950b99bc4 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -15225,6 +15225,118 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Vorheriger Konflikt'; + @override + String get settings_conflict_ref_buddy => 'Tauchpartner'; + + @override + String get settings_conflict_ref_certification => 'Zertifizierung'; + + @override + String get settings_conflict_ref_checklistTemplate => 'Checklisten-Vorlage'; + + @override + String get settings_conflict_ref_connectedAccount => 'Verbundenes Konto'; + + @override + String get settings_conflict_ref_course => 'Kurs'; + + @override + String get settings_conflict_ref_courseRequirement => 'Kursanforderung'; + + @override + String get settings_conflict_ref_cylinderConfig => 'Flaschenkonfiguration'; + + @override + String get settings_conflict_ref_dataSource => 'Datenquelle'; + + @override + String get settings_conflict_ref_dive => 'Tauchgang'; + + @override + String get settings_conflict_ref_diveCenter => 'Tauchbasis'; + + @override + String get settings_conflict_ref_diveComputer => 'Tauchcomputer'; + + @override + String get settings_conflict_ref_divePlan => 'Tauchplan'; + + @override + String get settings_conflict_ref_diveSite => 'Tauchplatz'; + + @override + String get settings_conflict_ref_diveType => 'Tauchgangart'; + + @override + String get settings_conflict_ref_diver => 'Taucher'; + + @override + String get settings_conflict_ref_equipment => 'Ausrüstung'; + + @override + String get settings_conflict_ref_equipmentSet => 'Ausrüstungsset'; + + @override + String get settings_conflict_ref_finding => 'Befund'; + + @override + String get settings_conflict_ref_instructor => 'Instructor'; + + @override + String get settings_conflict_ref_linkedDive => 'Verknüpfter Tauchgang'; + + @override + String get settings_conflict_ref_media => 'Medien'; + + @override + String get settings_conflict_ref_mediaSubscription => 'Medien-Abonnement'; + + @override + String get settings_conflict_ref_missing => 'Nicht mehr in dieser Bibliothek'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Geplante Flasche'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Vorlage für Checkliste vor dem Tauchgang'; + + @override + String get settings_conflict_ref_preDiveSession => + 'Checkliste vor dem Tauchgang'; + + @override + String get settings_conflict_ref_relatedDive => 'Zugehöriger Tauchgang'; + + @override + String get settings_conflict_ref_serviceKind => 'Wartungsart'; + + @override + String get settings_conflict_ref_sighting => 'Sichtung'; + + @override + String get settings_conflict_ref_signer => 'Unterschrieben von'; + + @override + String get settings_conflict_ref_sourceDive => 'Quell-Tauchgang'; + + @override + String get settings_conflict_ref_species => 'Art'; + + @override + String get settings_conflict_ref_tag => 'Tag'; + + @override + String get settings_conflict_ref_tank => 'Flasche'; + + @override + String get settings_conflict_ref_trip => 'Reise'; + @override String get settings_conflict_remoteVersion => 'Remote-Version'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..56e4cbd9f6 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -14991,6 +14991,117 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Previous conflict'; + @override + String get settings_conflict_ref_buddy => 'Buddy'; + + @override + String get settings_conflict_ref_certification => 'Certification'; + + @override + String get settings_conflict_ref_checklistTemplate => 'Checklist template'; + + @override + String get settings_conflict_ref_connectedAccount => 'Connected account'; + + @override + String get settings_conflict_ref_course => 'Course'; + + @override + String get settings_conflict_ref_courseRequirement => 'Course requirement'; + + @override + String get settings_conflict_ref_cylinderConfig => 'Cylinder configuration'; + + @override + String get settings_conflict_ref_dataSource => 'Data source'; + + @override + String get settings_conflict_ref_dive => 'Dive'; + + @override + String get settings_conflict_ref_diveCenter => 'Dive center'; + + @override + String get settings_conflict_ref_diveComputer => 'Dive computer'; + + @override + String get settings_conflict_ref_divePlan => 'Dive plan'; + + @override + String get settings_conflict_ref_diveSite => 'Dive site'; + + @override + String get settings_conflict_ref_diveType => 'Dive type'; + + @override + String get settings_conflict_ref_diver => 'Diver'; + + @override + String get settings_conflict_ref_equipment => 'Equipment'; + + @override + String get settings_conflict_ref_equipmentSet => 'Equipment set'; + + @override + String get settings_conflict_ref_finding => 'Finding'; + + @override + String get settings_conflict_ref_instructor => 'Instructor'; + + @override + String get settings_conflict_ref_linkedDive => 'Linked dive'; + + @override + String get settings_conflict_ref_media => 'Media'; + + @override + String get settings_conflict_ref_mediaSubscription => 'Media subscription'; + + @override + String get settings_conflict_ref_missing => 'No longer in this library'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Planned tank'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Pre-dive checklist template'; + + @override + String get settings_conflict_ref_preDiveSession => 'Pre-dive checklist run'; + + @override + String get settings_conflict_ref_relatedDive => 'Related dive'; + + @override + String get settings_conflict_ref_serviceKind => 'Service type'; + + @override + String get settings_conflict_ref_sighting => 'Sighting'; + + @override + String get settings_conflict_ref_signer => 'Signed by'; + + @override + String get settings_conflict_ref_sourceDive => 'Source dive'; + + @override + String get settings_conflict_ref_species => 'Species'; + + @override + String get settings_conflict_ref_tag => 'Tag'; + + @override + String get settings_conflict_ref_tank => 'Tank'; + + @override + String get settings_conflict_ref_trip => 'Trip'; + @override String get settings_conflict_remoteVersion => 'Remote Version'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..87e360c4fd 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -15238,6 +15238,121 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Conflicto anterior'; + @override + String get settings_conflict_ref_buddy => 'Compañero'; + + @override + String get settings_conflict_ref_certification => 'Certificacion'; + + @override + String get settings_conflict_ref_checklistTemplate => + 'Plantilla de lista de verificacion'; + + @override + String get settings_conflict_ref_connectedAccount => 'Cuenta conectada'; + + @override + String get settings_conflict_ref_course => 'Curso'; + + @override + String get settings_conflict_ref_courseRequirement => 'Requisito del curso'; + + @override + String get settings_conflict_ref_cylinderConfig => + 'Configuracion de botellas'; + + @override + String get settings_conflict_ref_dataSource => 'Fuente de datos'; + + @override + String get settings_conflict_ref_dive => 'Inmersion'; + + @override + String get settings_conflict_ref_diveCenter => 'Centro de buceo'; + + @override + String get settings_conflict_ref_diveComputer => 'Ordenador de buceo'; + + @override + String get settings_conflict_ref_divePlan => 'Plan de buceo'; + + @override + String get settings_conflict_ref_diveSite => 'Punto de buceo'; + + @override + String get settings_conflict_ref_diveType => 'Tipo de inmersion'; + + @override + String get settings_conflict_ref_diver => 'Buceador'; + + @override + String get settings_conflict_ref_equipment => 'Equipo'; + + @override + String get settings_conflict_ref_equipmentSet => 'Conjunto de equipo'; + + @override + String get settings_conflict_ref_finding => 'Hallazgo'; + + @override + String get settings_conflict_ref_instructor => 'Instructor'; + + @override + String get settings_conflict_ref_linkedDive => 'Inmersion vinculada'; + + @override + String get settings_conflict_ref_media => 'Multimedia'; + + @override + String get settings_conflict_ref_mediaSubscription => + 'Suscripcion multimedia'; + + @override + String get settings_conflict_ref_missing => 'Ya no esta en esta biblioteca'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Tanque planificado'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Plantilla de lista previa a la inmersion'; + + @override + String get settings_conflict_ref_preDiveSession => + 'Lista previa a la inmersion'; + + @override + String get settings_conflict_ref_relatedDive => 'Inmersion relacionada'; + + @override + String get settings_conflict_ref_serviceKind => 'Tipo de servicio'; + + @override + String get settings_conflict_ref_sighting => 'Avistamiento'; + + @override + String get settings_conflict_ref_signer => 'Firmado por'; + + @override + String get settings_conflict_ref_sourceDive => 'Inmersion de origen'; + + @override + String get settings_conflict_ref_species => 'Especies'; + + @override + String get settings_conflict_ref_tag => 'Etiqueta'; + + @override + String get settings_conflict_ref_tank => 'Tanque'; + + @override + String get settings_conflict_ref_trip => 'Viaje'; + @override String get settings_conflict_remoteVersion => 'Version remota'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..6526d17490 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -15286,6 +15286,119 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Conflit precedent'; + @override + String get settings_conflict_ref_buddy => 'Binome'; + + @override + String get settings_conflict_ref_certification => 'Certification'; + + @override + String get settings_conflict_ref_checklistTemplate => + 'Modele de liste de controle'; + + @override + String get settings_conflict_ref_connectedAccount => 'Compte connecte'; + + @override + String get settings_conflict_ref_course => 'Cours'; + + @override + String get settings_conflict_ref_courseRequirement => 'Exigence du cours'; + + @override + String get settings_conflict_ref_cylinderConfig => 'Configuration de blocs'; + + @override + String get settings_conflict_ref_dataSource => 'Source de donnees'; + + @override + String get settings_conflict_ref_dive => 'Plongee'; + + @override + String get settings_conflict_ref_diveCenter => 'Centre de plongee'; + + @override + String get settings_conflict_ref_diveComputer => 'Ordinateur de plongee'; + + @override + String get settings_conflict_ref_divePlan => 'Plan de plongee'; + + @override + String get settings_conflict_ref_diveSite => 'Site de plongee'; + + @override + String get settings_conflict_ref_diveType => 'Type de plongee'; + + @override + String get settings_conflict_ref_diver => 'Plongeur'; + + @override + String get settings_conflict_ref_equipment => 'Equipement'; + + @override + String get settings_conflict_ref_equipmentSet => 'Ensemble d\'equipement'; + + @override + String get settings_conflict_ref_finding => 'Anomalie'; + + @override + String get settings_conflict_ref_instructor => 'Moniteur'; + + @override + String get settings_conflict_ref_linkedDive => 'Plongee liee'; + + @override + String get settings_conflict_ref_media => 'Medias'; + + @override + String get settings_conflict_ref_mediaSubscription => 'Abonnement media'; + + @override + String get settings_conflict_ref_missing => + 'N\'est plus dans cette bibliotheque'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Bloc planifie'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Modele de liste avant plongee'; + + @override + String get settings_conflict_ref_preDiveSession => 'Liste avant plongee'; + + @override + String get settings_conflict_ref_relatedDive => 'Plongee associee'; + + @override + String get settings_conflict_ref_serviceKind => 'Type de revision'; + + @override + String get settings_conflict_ref_sighting => 'Observation'; + + @override + String get settings_conflict_ref_signer => 'Signe par'; + + @override + String get settings_conflict_ref_sourceDive => 'Plongee source'; + + @override + String get settings_conflict_ref_species => 'Especes'; + + @override + String get settings_conflict_ref_tag => 'Etiquette'; + + @override + String get settings_conflict_ref_tank => 'Bloc'; + + @override + String get settings_conflict_ref_trip => 'Voyage'; + @override String get settings_conflict_remoteVersion => 'Version distante'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..84578c28c8 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -14866,6 +14866,117 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'ההתנגשות הקודמת'; + @override + String get settings_conflict_ref_buddy => 'שותף'; + + @override + String get settings_conflict_ref_certification => 'הסמכה'; + + @override + String get settings_conflict_ref_checklistTemplate => 'תבנית רשימת משימות'; + + @override + String get settings_conflict_ref_connectedAccount => 'חשבון מחובר'; + + @override + String get settings_conflict_ref_course => 'קורס'; + + @override + String get settings_conflict_ref_courseRequirement => 'דרישת קורס'; + + @override + String get settings_conflict_ref_cylinderConfig => 'תצורת בלונים'; + + @override + String get settings_conflict_ref_dataSource => 'מקור נתונים'; + + @override + String get settings_conflict_ref_dive => 'צלילה'; + + @override + String get settings_conflict_ref_diveCenter => 'מועדון צלילה'; + + @override + String get settings_conflict_ref_diveComputer => 'מחשב צלילה'; + + @override + String get settings_conflict_ref_divePlan => 'תוכנית צלילה'; + + @override + String get settings_conflict_ref_diveSite => 'אתר צלילה'; + + @override + String get settings_conflict_ref_diveType => 'סוג צלילה'; + + @override + String get settings_conflict_ref_diver => 'צולל'; + + @override + String get settings_conflict_ref_equipment => 'ציוד'; + + @override + String get settings_conflict_ref_equipmentSet => 'סט ציוד'; + + @override + String get settings_conflict_ref_finding => 'ממצא'; + + @override + String get settings_conflict_ref_instructor => 'מדריך'; + + @override + String get settings_conflict_ref_linkedDive => 'צלילה מקושרת'; + + @override + String get settings_conflict_ref_media => 'מדיה'; + + @override + String get settings_conflict_ref_mediaSubscription => 'מנוי מדיה'; + + @override + String get settings_conflict_ref_missing => 'כבר לא בספרייה הזו'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'בלון מתוכנן'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'תבנית רשימת בדיקות לפני צלילה'; + + @override + String get settings_conflict_ref_preDiveSession => 'רשימת בדיקות לפני צלילה'; + + @override + String get settings_conflict_ref_relatedDive => 'צלילה קשורה'; + + @override + String get settings_conflict_ref_serviceKind => 'סוג טיפול'; + + @override + String get settings_conflict_ref_sighting => 'תצפית'; + + @override + String get settings_conflict_ref_signer => 'נחתם על ידי'; + + @override + String get settings_conflict_ref_sourceDive => 'צלילת מקור'; + + @override + String get settings_conflict_ref_species => 'מינים'; + + @override + String get settings_conflict_ref_tag => 'תגית'; + + @override + String get settings_conflict_ref_tank => 'בלון'; + + @override + String get settings_conflict_ref_trip => 'טיול'; + @override String get settings_conflict_remoteVersion => 'גרסה מרוחקת'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..6d436ca836 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -15195,6 +15195,119 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Elozo ütközes'; + @override + String get settings_conflict_ref_buddy => 'Buvartars'; + + @override + String get settings_conflict_ref_certification => 'Kepesites'; + + @override + String get settings_conflict_ref_checklistTemplate => 'Ellenorzolista sablon'; + + @override + String get settings_conflict_ref_connectedAccount => 'Csatlakoztatott fiok'; + + @override + String get settings_conflict_ref_course => 'Tanfolyam'; + + @override + String get settings_conflict_ref_courseRequirement => + 'Tanfolyami kovetelmeny'; + + @override + String get settings_conflict_ref_cylinderConfig => 'Palackkonfiguracio'; + + @override + String get settings_conflict_ref_dataSource => 'Adatforras'; + + @override + String get settings_conflict_ref_dive => 'Merules'; + + @override + String get settings_conflict_ref_diveCenter => 'Merulocentrum'; + + @override + String get settings_conflict_ref_diveComputer => 'Merulesszamitogep'; + + @override + String get settings_conflict_ref_divePlan => 'Merulesi terv'; + + @override + String get settings_conflict_ref_diveSite => 'Merulohely'; + + @override + String get settings_conflict_ref_diveType => 'Merules tipusa'; + + @override + String get settings_conflict_ref_diver => 'Merulo'; + + @override + String get settings_conflict_ref_equipment => 'Felszereles'; + + @override + String get settings_conflict_ref_equipmentSet => 'Felszereleskeszlet'; + + @override + String get settings_conflict_ref_finding => 'Eszrevetel'; + + @override + String get settings_conflict_ref_instructor => 'Oktato'; + + @override + String get settings_conflict_ref_linkedDive => 'Kapcsolt merules'; + + @override + String get settings_conflict_ref_media => 'Media'; + + @override + String get settings_conflict_ref_mediaSubscription => 'Media-elofizetes'; + + @override + String get settings_conflict_ref_missing => 'Mar nincs ebben a konyvtarban'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Tervezett palack'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Merules elotti ellenorzolista sablon'; + + @override + String get settings_conflict_ref_preDiveSession => + 'Merules elotti ellenorzolista'; + + @override + String get settings_conflict_ref_relatedDive => 'Kapcsolodo merules'; + + @override + String get settings_conflict_ref_serviceKind => 'Szerviz tipusa'; + + @override + String get settings_conflict_ref_sighting => 'Eszleles'; + + @override + String get settings_conflict_ref_signer => 'Alairta'; + + @override + String get settings_conflict_ref_sourceDive => 'Forras merules'; + + @override + String get settings_conflict_ref_species => 'Fajok'; + + @override + String get settings_conflict_ref_tag => 'Cimke'; + + @override + String get settings_conflict_ref_tank => 'Palack'; + + @override + String get settings_conflict_ref_trip => 'Utazas'; + @override String get settings_conflict_remoteVersion => 'Tavoli valtozat'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..e4aecd94bb 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -15245,6 +15245,118 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Conflitto precedente'; + @override + String get settings_conflict_ref_buddy => 'Compagno'; + + @override + String get settings_conflict_ref_certification => 'Certificazione'; + + @override + String get settings_conflict_ref_checklistTemplate => + 'Modello di lista di controllo'; + + @override + String get settings_conflict_ref_connectedAccount => 'Account collegato'; + + @override + String get settings_conflict_ref_course => 'Corso'; + + @override + String get settings_conflict_ref_courseRequirement => 'Requisito del corso'; + + @override + String get settings_conflict_ref_cylinderConfig => 'Configurazione bombole'; + + @override + String get settings_conflict_ref_dataSource => 'Origine dati'; + + @override + String get settings_conflict_ref_dive => 'Immersione'; + + @override + String get settings_conflict_ref_diveCenter => 'Centro immersioni'; + + @override + String get settings_conflict_ref_diveComputer => 'Computer subacqueo'; + + @override + String get settings_conflict_ref_divePlan => 'Piano di immersione'; + + @override + String get settings_conflict_ref_diveSite => 'Sito di immersione'; + + @override + String get settings_conflict_ref_diveType => 'Tipo di immersione'; + + @override + String get settings_conflict_ref_diver => 'Subacqueo'; + + @override + String get settings_conflict_ref_equipment => 'Attrezzatura'; + + @override + String get settings_conflict_ref_equipmentSet => 'Set di attrezzatura'; + + @override + String get settings_conflict_ref_finding => 'Rilievo'; + + @override + String get settings_conflict_ref_instructor => 'Istruttore'; + + @override + String get settings_conflict_ref_linkedDive => 'Immersione collegata'; + + @override + String get settings_conflict_ref_media => 'Media'; + + @override + String get settings_conflict_ref_mediaSubscription => 'Abbonamento media'; + + @override + String get settings_conflict_ref_missing => 'Non è più in questa libreria'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Bombola pianificata'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Modello di lista pre-immersione'; + + @override + String get settings_conflict_ref_preDiveSession => 'Lista pre-immersione'; + + @override + String get settings_conflict_ref_relatedDive => 'Immersione correlata'; + + @override + String get settings_conflict_ref_serviceKind => 'Tipo di revisione'; + + @override + String get settings_conflict_ref_sighting => 'Avvistamento'; + + @override + String get settings_conflict_ref_signer => 'Firmato da'; + + @override + String get settings_conflict_ref_sourceDive => 'Immersione di origine'; + + @override + String get settings_conflict_ref_species => 'Specie'; + + @override + String get settings_conflict_ref_tag => 'Tag'; + + @override + String get settings_conflict_ref_tank => 'Bombola'; + + @override + String get settings_conflict_ref_trip => 'Viaggio'; + @override String get settings_conflict_remoteVersion => 'Versione remota'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..922b3c6c03 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -15128,6 +15128,117 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Vorig conflict'; + @override + String get settings_conflict_ref_buddy => 'Buddy'; + + @override + String get settings_conflict_ref_certification => 'Certificering'; + + @override + String get settings_conflict_ref_checklistTemplate => 'Checklistsjabloon'; + + @override + String get settings_conflict_ref_connectedAccount => 'Gekoppeld account'; + + @override + String get settings_conflict_ref_course => 'Cursus'; + + @override + String get settings_conflict_ref_courseRequirement => 'Cursusvereiste'; + + @override + String get settings_conflict_ref_cylinderConfig => 'Flesconfiguratie'; + + @override + String get settings_conflict_ref_dataSource => 'Gegevensbron'; + + @override + String get settings_conflict_ref_dive => 'Duik'; + + @override + String get settings_conflict_ref_diveCenter => 'Duikcentrum'; + + @override + String get settings_conflict_ref_diveComputer => 'Duikcomputer'; + + @override + String get settings_conflict_ref_divePlan => 'Duikplan'; + + @override + String get settings_conflict_ref_diveSite => 'Duikstek'; + + @override + String get settings_conflict_ref_diveType => 'Duiktype'; + + @override + String get settings_conflict_ref_diver => 'Duiker'; + + @override + String get settings_conflict_ref_equipment => 'Uitrusting'; + + @override + String get settings_conflict_ref_equipmentSet => 'Uitrustingsset'; + + @override + String get settings_conflict_ref_finding => 'Bevinding'; + + @override + String get settings_conflict_ref_instructor => 'Instructeur'; + + @override + String get settings_conflict_ref_linkedDive => 'Gekoppelde duik'; + + @override + String get settings_conflict_ref_media => 'Media'; + + @override + String get settings_conflict_ref_mediaSubscription => 'Media-abonnement'; + + @override + String get settings_conflict_ref_missing => 'Niet meer in deze bibliotheek'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Geplande fles'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Sjabloon voor checklist voor de duik'; + + @override + String get settings_conflict_ref_preDiveSession => 'Checklist voor de duik'; + + @override + String get settings_conflict_ref_relatedDive => 'Gerelateerde duik'; + + @override + String get settings_conflict_ref_serviceKind => 'Onderhoudstype'; + + @override + String get settings_conflict_ref_sighting => 'Waarneming'; + + @override + String get settings_conflict_ref_signer => 'Ondertekend door'; + + @override + String get settings_conflict_ref_sourceDive => 'Bronduik'; + + @override + String get settings_conflict_ref_species => 'Soorten'; + + @override + String get settings_conflict_ref_tag => 'Tag'; + + @override + String get settings_conflict_ref_tank => 'Fles'; + + @override + String get settings_conflict_ref_trip => 'Reis'; + @override String get settings_conflict_remoteVersion => 'Externe versie'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..4a5541758c 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -15243,6 +15243,119 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_conflict_previous_tooltip => 'Conflito anterior'; + @override + String get settings_conflict_ref_buddy => 'Companheiro'; + + @override + String get settings_conflict_ref_certification => 'Certificacao'; + + @override + String get settings_conflict_ref_checklistTemplate => + 'Modelo de lista de verificacao'; + + @override + String get settings_conflict_ref_connectedAccount => 'Conta conectada'; + + @override + String get settings_conflict_ref_course => 'Curso'; + + @override + String get settings_conflict_ref_courseRequirement => 'Requisito do curso'; + + @override + String get settings_conflict_ref_cylinderConfig => + 'Configuracao de cilindros'; + + @override + String get settings_conflict_ref_dataSource => 'Fonte de dados'; + + @override + String get settings_conflict_ref_dive => 'Mergulho'; + + @override + String get settings_conflict_ref_diveCenter => 'Operadora de Mergulho'; + + @override + String get settings_conflict_ref_diveComputer => 'Computador de Mergulho'; + + @override + String get settings_conflict_ref_divePlan => 'Plano de mergulho'; + + @override + String get settings_conflict_ref_diveSite => 'Ponto de Mergulho'; + + @override + String get settings_conflict_ref_diveType => 'Tipo de Mergulho'; + + @override + String get settings_conflict_ref_diver => 'Mergulhador'; + + @override + String get settings_conflict_ref_equipment => 'Equipamento'; + + @override + String get settings_conflict_ref_equipmentSet => 'Conjunto de equipamentos'; + + @override + String get settings_conflict_ref_finding => 'Constatacao'; + + @override + String get settings_conflict_ref_instructor => 'Instrutor'; + + @override + String get settings_conflict_ref_linkedDive => 'Mergulho vinculado'; + + @override + String get settings_conflict_ref_media => 'Midia'; + + @override + String get settings_conflict_ref_mediaSubscription => 'Assinatura de midia'; + + @override + String get settings_conflict_ref_missing => 'Nao esta mais nesta biblioteca'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name ($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => 'Cilindro planejado'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => + 'Modelo de lista pre-mergulho'; + + @override + String get settings_conflict_ref_preDiveSession => 'Lista pre-mergulho'; + + @override + String get settings_conflict_ref_relatedDive => 'Mergulho relacionado'; + + @override + String get settings_conflict_ref_serviceKind => 'Tipo de manutencao'; + + @override + String get settings_conflict_ref_sighting => 'Avistamento'; + + @override + String get settings_conflict_ref_signer => 'Assinado por'; + + @override + String get settings_conflict_ref_sourceDive => 'Mergulho de origem'; + + @override + String get settings_conflict_ref_species => 'Especies'; + + @override + String get settings_conflict_ref_tag => 'Etiqueta'; + + @override + String get settings_conflict_ref_tank => 'Cilindro'; + + @override + String get settings_conflict_ref_trip => 'Viagem'; + @override String get settings_conflict_remoteVersion => 'Versao Remota'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..7582e71121 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -14512,6 +14512,116 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_conflict_previous_tooltip => '上一个冲突'; + @override + String get settings_conflict_ref_buddy => '潜伴'; + + @override + String get settings_conflict_ref_certification => '证书'; + + @override + String get settings_conflict_ref_checklistTemplate => '清单模板'; + + @override + String get settings_conflict_ref_connectedAccount => '已连接账户'; + + @override + String get settings_conflict_ref_course => '课程'; + + @override + String get settings_conflict_ref_courseRequirement => '课程要求'; + + @override + String get settings_conflict_ref_cylinderConfig => '气瓶配置'; + + @override + String get settings_conflict_ref_dataSource => '数据来源'; + + @override + String get settings_conflict_ref_dive => '潜水'; + + @override + String get settings_conflict_ref_diveCenter => '潜水中心'; + + @override + String get settings_conflict_ref_diveComputer => '潜水电脑'; + + @override + String get settings_conflict_ref_divePlan => '潜水计划'; + + @override + String get settings_conflict_ref_diveSite => '潜水点'; + + @override + String get settings_conflict_ref_diveType => '潜水类型'; + + @override + String get settings_conflict_ref_diver => '潜水员'; + + @override + String get settings_conflict_ref_equipment => '装备'; + + @override + String get settings_conflict_ref_equipmentSet => '装备套装'; + + @override + String get settings_conflict_ref_finding => '发现项'; + + @override + String get settings_conflict_ref_instructor => '教练'; + + @override + String get settings_conflict_ref_linkedDive => '关联潜水'; + + @override + String get settings_conflict_ref_media => '媒体'; + + @override + String get settings_conflict_ref_mediaSubscription => '媒体订阅'; + + @override + String get settings_conflict_ref_missing => '已不在此库中'; + + @override + String settings_conflict_ref_named(Object name, Object date) { + return '$name($date)'; + } + + @override + String get settings_conflict_ref_plannedTank => '计划气瓶'; + + @override + String get settings_conflict_ref_preDiveChecklistTemplate => '潜前清单模板'; + + @override + String get settings_conflict_ref_preDiveSession => '潜前清单'; + + @override + String get settings_conflict_ref_relatedDive => '相关潜水'; + + @override + String get settings_conflict_ref_serviceKind => '维护类型'; + + @override + String get settings_conflict_ref_sighting => '目击记录'; + + @override + String get settings_conflict_ref_signer => '签署人'; + + @override + String get settings_conflict_ref_sourceDive => '源潜水'; + + @override + String get settings_conflict_ref_species => '物种'; + + @override + String get settings_conflict_ref_tag => '标签'; + + @override + String get settings_conflict_ref_tank => '气瓶'; + + @override + String get settings_conflict_ref_trip => '行程'; + @override String get settings_conflict_remoteVersion => '远程版本'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..694f13a209 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -4600,6 +4600,42 @@ "settings_conflict_noConflicts_title": "Geen conflicten", "settings_conflict_noDataAvailable": "Geen gegevens beschikbaar", "settings_conflict_previous_tooltip": "Vorig conflict", + "settings_conflict_ref_buddy": "Buddy", + "settings_conflict_ref_certification": "Certificering", + "settings_conflict_ref_checklistTemplate": "Checklistsjabloon", + "settings_conflict_ref_connectedAccount": "Gekoppeld account", + "settings_conflict_ref_course": "Cursus", + "settings_conflict_ref_courseRequirement": "Cursusvereiste", + "settings_conflict_ref_cylinderConfig": "Flesconfiguratie", + "settings_conflict_ref_dataSource": "Gegevensbron", + "settings_conflict_ref_dive": "Duik", + "settings_conflict_ref_diveCenter": "Duikcentrum", + "settings_conflict_ref_diveComputer": "Duikcomputer", + "settings_conflict_ref_divePlan": "Duikplan", + "settings_conflict_ref_diveSite": "Duikstek", + "settings_conflict_ref_diveType": "Duiktype", + "settings_conflict_ref_diver": "Duiker", + "settings_conflict_ref_equipment": "Uitrusting", + "settings_conflict_ref_equipmentSet": "Uitrustingsset", + "settings_conflict_ref_finding": "Bevinding", + "settings_conflict_ref_instructor": "Instructeur", + "settings_conflict_ref_linkedDive": "Gekoppelde duik", + "settings_conflict_ref_media": "Media", + "settings_conflict_ref_mediaSubscription": "Media-abonnement", + "settings_conflict_ref_missing": "Niet meer in deze bibliotheek", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Geplande fles", + "settings_conflict_ref_preDiveChecklistTemplate": "Sjabloon voor checklist voor de duik", + "settings_conflict_ref_preDiveSession": "Checklist voor de duik", + "settings_conflict_ref_relatedDive": "Gerelateerde duik", + "settings_conflict_ref_serviceKind": "Onderhoudstype", + "settings_conflict_ref_sighting": "Waarneming", + "settings_conflict_ref_signer": "Ondertekend door", + "settings_conflict_ref_sourceDive": "Bronduik", + "settings_conflict_ref_species": "Soorten", + "settings_conflict_ref_tag": "Tag", + "settings_conflict_ref_tank": "Fles", + "settings_conflict_ref_trip": "Reis", "settings_conflict_remoteVersion": "Externe versie", "settings_conflict_resolved": "{count, plural, =1{1 conflict} other{{count} conflicten}} opgelost", "settings_conflict_title": "Conflicten oplossen", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..7be996c1c5 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -4600,6 +4600,42 @@ "settings_conflict_noConflicts_title": "Sem Conflitos", "settings_conflict_noDataAvailable": "Nenhum dado disponivel", "settings_conflict_previous_tooltip": "Conflito anterior", + "settings_conflict_ref_buddy": "Companheiro", + "settings_conflict_ref_certification": "Certificacao", + "settings_conflict_ref_checklistTemplate": "Modelo de lista de verificacao", + "settings_conflict_ref_connectedAccount": "Conta conectada", + "settings_conflict_ref_course": "Curso", + "settings_conflict_ref_courseRequirement": "Requisito do curso", + "settings_conflict_ref_cylinderConfig": "Configuracao de cilindros", + "settings_conflict_ref_dataSource": "Fonte de dados", + "settings_conflict_ref_dive": "Mergulho", + "settings_conflict_ref_diveCenter": "Operadora de Mergulho", + "settings_conflict_ref_diveComputer": "Computador de Mergulho", + "settings_conflict_ref_divePlan": "Plano de mergulho", + "settings_conflict_ref_diveSite": "Ponto de Mergulho", + "settings_conflict_ref_diveType": "Tipo de Mergulho", + "settings_conflict_ref_diver": "Mergulhador", + "settings_conflict_ref_equipment": "Equipamento", + "settings_conflict_ref_equipmentSet": "Conjunto de equipamentos", + "settings_conflict_ref_finding": "Constatacao", + "settings_conflict_ref_instructor": "Instrutor", + "settings_conflict_ref_linkedDive": "Mergulho vinculado", + "settings_conflict_ref_media": "Midia", + "settings_conflict_ref_mediaSubscription": "Assinatura de midia", + "settings_conflict_ref_missing": "Nao esta mais nesta biblioteca", + "settings_conflict_ref_named": "{name} ({date})", + "settings_conflict_ref_plannedTank": "Cilindro planejado", + "settings_conflict_ref_preDiveChecklistTemplate": "Modelo de lista pre-mergulho", + "settings_conflict_ref_preDiveSession": "Lista pre-mergulho", + "settings_conflict_ref_relatedDive": "Mergulho relacionado", + "settings_conflict_ref_serviceKind": "Tipo de manutencao", + "settings_conflict_ref_sighting": "Avistamento", + "settings_conflict_ref_signer": "Assinado por", + "settings_conflict_ref_sourceDive": "Mergulho de origem", + "settings_conflict_ref_species": "Especies", + "settings_conflict_ref_tag": "Etiqueta", + "settings_conflict_ref_tank": "Cilindro", + "settings_conflict_ref_trip": "Viagem", "settings_conflict_remoteVersion": "Versao Remota", "settings_conflict_resolved": "{count, plural, =1{1 conflito resolvido} other{{count} conflitos resolvidos}}", "settings_conflict_title": "Resolver Conflitos", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..e2ffe58725 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -4757,6 +4757,42 @@ "settings_conflict_noConflicts_title": "无冲突", "settings_conflict_noDataAvailable": "无可用数据", "settings_conflict_previous_tooltip": "上一个冲突", + "settings_conflict_ref_buddy": "潜伴", + "settings_conflict_ref_certification": "证书", + "settings_conflict_ref_checklistTemplate": "清单模板", + "settings_conflict_ref_connectedAccount": "已连接账户", + "settings_conflict_ref_course": "课程", + "settings_conflict_ref_courseRequirement": "课程要求", + "settings_conflict_ref_cylinderConfig": "气瓶配置", + "settings_conflict_ref_dataSource": "数据来源", + "settings_conflict_ref_dive": "潜水", + "settings_conflict_ref_diveCenter": "潜水中心", + "settings_conflict_ref_diveComputer": "潜水电脑", + "settings_conflict_ref_divePlan": "潜水计划", + "settings_conflict_ref_diveSite": "潜水点", + "settings_conflict_ref_diveType": "潜水类型", + "settings_conflict_ref_diver": "潜水员", + "settings_conflict_ref_equipment": "装备", + "settings_conflict_ref_equipmentSet": "装备套装", + "settings_conflict_ref_finding": "发现项", + "settings_conflict_ref_instructor": "教练", + "settings_conflict_ref_linkedDive": "关联潜水", + "settings_conflict_ref_media": "媒体", + "settings_conflict_ref_mediaSubscription": "媒体订阅", + "settings_conflict_ref_missing": "已不在此库中", + "settings_conflict_ref_named": "{name}({date})", + "settings_conflict_ref_plannedTank": "计划气瓶", + "settings_conflict_ref_preDiveChecklistTemplate": "潜前清单模板", + "settings_conflict_ref_preDiveSession": "潜前清单", + "settings_conflict_ref_relatedDive": "相关潜水", + "settings_conflict_ref_serviceKind": "维护类型", + "settings_conflict_ref_sighting": "目击记录", + "settings_conflict_ref_signer": "签署人", + "settings_conflict_ref_sourceDive": "源潜水", + "settings_conflict_ref_species": "物种", + "settings_conflict_ref_tag": "标签", + "settings_conflict_ref_tank": "气瓶", + "settings_conflict_ref_trip": "行程", "settings_conflict_remoteVersion": "远程版本", "settings_conflict_resolved": "已解决 {count, plural, =1{1 个冲突} other{{count} 个冲突}}", "settings_conflict_title": "解决冲突", diff --git a/test/core/services/sync/conflict_reference_resolver_test.dart b/test/core/services/sync/conflict_reference_resolver_test.dart new file mode 100644 index 0000000000..44fb3bb2d9 --- /dev/null +++ b/test/core/services/sync/conflict_reference_resolver_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/sync/conflict_reference.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; + +import '../../../helpers/mock_providers.dart'; +import '../../../helpers/test_database.dart'; + +/// Unit coverage for [ConflictReferenceResolver] (#1031): the lookup step that +/// turns the foreign-key columns of a conflicting record into the referenced +/// rows' real-world anchors, so the Resolve Conflicts dialog can name a tag and +/// date a dive instead of printing two UUIDs. +void main() { + late AppDatabase db; + late SyncDataSerializer serializer; + late ConflictReferenceResolver resolver; + + setUp(() async { + db = await setUpTestDatabase(); + serializer = SyncDataSerializer(); + resolver = ConflictReferenceResolver(serializer); + }); + tearDown(tearDownTestDatabase); + + Future seedTag(String id, String name) => serializer.upsertRecord( + 'tags', + {'id': id, 'name': name, 'createdAt': 1000, 'updatedAt': 1000}, + ); + + Future seedSite(String id, String name) => + serializer.upsertRecord('diveSites', { + 'id': id, + 'name': name, + 'description': '', + 'notes': '', + 'isShared': false, + 'createdAt': 1000, + 'updatedAt': 1000, + }); + + Future seedDive(String id, {String? siteId}) async { + await DiveRepository().createDive( + createTestDiveWithBottomTime(id: id, diveNumber: 1), + ); + if (siteId != null) { + await db.customStatement('UPDATE dives SET site_id = ? WHERE id = ?', [ + siteId, + id, + ]); + } + } + + ConflictReference refFor(List refs, String field) => + refs.firstWhere((r) => r.field == field); + + test('resolves both foreign keys of a diveTags junction row', () async { + await seedSite('site-1', 'Blue Hole'); + await seedDive('dive-1', siteId: 'site-1'); + await seedTag('tag-1', 'Wreck'); + + final refs = await resolver.resolve('diveTags', { + 'id': 'junction-1', + 'diveId': 'dive-1', + 'tagId': 'tag-1', + 'createdAt': 1786556582600, + }); + + expect(refs, hasLength(2)); + + final tag = refFor(refs, 'tagId'); + expect(tag.targetType, 'tags'); + expect(tag.recordId, 'tag-1'); + expect(tag.name, 'Wreck'); + expect(tag.isMissing, isFalse); + + final dive = refFor(refs, 'diveId'); + expect(dive.targetType, 'dives'); + expect(dive.name, 'Blue Hole', reason: 'a dive is named by its site'); + expect(dive.timestamp, DateTime(2026, 3, 28, 10, 0)); + }); + + test('marks a reference whose row is absent locally as missing', () async { + await seedDive('dive-1'); + + final refs = await resolver.resolve('diveTags', { + 'id': 'junction-1', + 'diveId': 'dive-1', + 'tagId': 'tag-gone', + 'createdAt': 1000, + }); + + final tag = refFor(refs, 'tagId'); + expect(tag.isMissing, isTrue); + expect(tag.name, isNull); + expect(tag.recordId, 'tag-gone'); + }); + + test('a dive with no site is still anchored by its date', () async { + await seedDive('dive-1'); + + final refs = await resolver.resolve('diveTags', { + 'diveId': 'dive-1', + 'tagId': 'tag-1', + }); + + final dive = refFor(refs, 'diveId'); + expect(dive.name, isNull); + expect(dive.timestamp, DateTime(2026, 3, 28, 10, 0)); + expect(dive.isMissing, isFalse); + }); + + test('ignores the record own id and non-reference fields', () async { + await seedDive('dive-1'); + + final refs = await resolver.resolve('qualityFindings', { + 'id': 'finding-1', + 'diveId': 'dive-1', + 'detectorId': 'depth_spike', + 'detectorVersion': 1, + 'params': '{"atSeconds":120}', + 'createdAt': 1000, + }); + + expect(refs.map((r) => r.field), ['diveId']); + }); + + test('skips null foreign keys', () async { + await seedDive('dive-1'); + + final refs = await resolver.resolve('qualityFindings', { + 'id': 'finding-1', + 'diveId': 'dive-1', + 'relatedDiveId': null, + 'computerId': null, + }); + + expect(refs.map((r) => r.field), ['diveId']); + }); + + test('resolves a nullable cross-dive reference when it is set', () async { + await seedDive('dive-1'); + await seedSite('site-2', 'The Arch'); + await seedDive('dive-2', siteId: 'site-2'); + + final refs = await resolver.resolve('qualityFindings', { + 'id': 'finding-1', + 'diveId': 'dive-1', + 'relatedDiveId': 'dive-2', + }); + + expect(refFor(refs, 'relatedDiveId').name, 'The Arch'); + }); + + test('disambiguates templateId by the owning entity type', () async { + await serializer.upsertRecord('preDiveChecklistTemplates', { + 'id': 'tpl-1', + 'name': 'Pre-dive buddy check', + 'createdAt': 1000, + 'updatedAt': 1000, + }); + await serializer.upsertRecord('checklistTemplates', { + 'id': 'tpl-1', + 'name': 'Trip packing list', + 'createdAt': 1000, + 'updatedAt': 1000, + }); + + final preDive = await resolver.resolve('preDiveSessions', { + 'id': 'session-1', + 'templateId': 'tpl-1', + }); + expect( + refFor(preDive, 'templateId').targetType, + 'preDiveChecklistTemplates', + ); + expect(refFor(preDive, 'templateId').name, 'Pre-dive buddy check'); + + final trip = await resolver.resolve('checklistTemplateItems', { + 'id': 'item-1', + 'templateId': 'tpl-1', + }); + expect(refFor(trip, 'templateId').targetType, 'checklistTemplates'); + expect(refFor(trip, 'templateId').name, 'Trip packing list'); + }); + + test('resolves a species sighting by its common name', () async { + await serializer.upsertRecord('species', { + 'id': 'sp-1', + 'commonName': 'Manta ray', + 'scientificName': 'Mobula birostris', + 'category': 'fish', + }); + await seedDive('dive-1'); + + final refs = await resolver.resolve('sightings', { + 'id': 'sighting-1', + 'diveId': 'dive-1', + 'speciesId': 'sp-1', + }); + + expect(refFor(refs, 'speciesId').name, 'Manta ray'); + }); + + test('returns nothing for an entity with no foreign keys', () async { + final refs = await resolver.resolve('tags', { + 'id': 'tag-1', + 'name': 'Wreck', + 'createdAt': 1000, + }); + + expect(refs, isEmpty); + }); +} diff --git a/test/core/services/sync/sync_conflict_resolution_test.dart b/test/core/services/sync/sync_conflict_resolution_test.dart index bf49c6d7b2..281fff7ae7 100644 --- a/test/core/services/sync/sync_conflict_resolution_test.dart +++ b/test/core/services/sync/sync_conflict_resolution_test.dart @@ -73,6 +73,46 @@ void main() { expect(conflicts.first.recordId, 'd-getc'); }); + test('getConflicts resolves foreign keys on both sides (#1031)', () async { + final serializer = SyncDataSerializer(); + await serializer.upsertRecord('tags', { + 'id': 'tag-local', + 'name': 'Wreck', + 'createdAt': 1000, + 'updatedAt': 1000, + }); + await serializer.upsertRecord('tags', { + 'id': 'tag-remote', + 'name': 'Night', + 'createdAt': 1000, + 'updatedAt': 1000, + }); + await seedDive('d-refs', 10); + await serializer.upsertRecord('diveTags', { + 'id': 'dt-1', + 'diveId': 'd-refs', + 'tagId': 'tag-local', + 'createdAt': 1000, + }); + await raiseConflict('diveTags', 'dt-1', { + 'id': 'dt-1', + 'diveId': 'd-refs', + 'tagId': 'tag-remote', + 'createdAt': 2000, + }); + + final conflict = (await buildService().getConflicts()).single; + + expect( + conflict.localReferences.firstWhere((r) => r.field == 'tagId').name, + 'Wreck', + ); + expect( + conflict.remoteReferences.firstWhere((r) => r.field == 'tagId').name, + 'Night', + ); + }); + test( 'keepLocal preserves the local value and clears the conflict', () async { diff --git a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart new file mode 100644 index 0000000000..d1b080f7fe --- /dev/null +++ b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/sync/conflict_reference.dart'; +import 'package:submersion/core/services/sync/sync_service.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/conflict_resolution_dialog.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +/// Widget coverage for the Resolve Conflicts dialog's data preview (#1031). +/// Junction and relation entities carry nothing but foreign keys, so the +/// preview has to lead with the resolved references; showing raw UUIDs and an +/// epoch timestamp gives the user nothing to decide with. +void main() { + final diveDate = DateTime(2026, 3, 28, 10, 0); + + Future pumpDialog(WidgetTester tester, SyncConflict conflict) async { + final base = await getBaseOverrides(); + await tester.binding.setSurfaceSize(const Size(600, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...base, + conflictsProvider.overrideWith((ref) async => [conflict]), + ], + child: const MaterialApp( + // Pinned so the English literals asserted below cannot depend on + // the host's default locale. + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: ConflictResolutionDialog()), + ), + ), + ); + await tester.pumpAndSettle(); + } + + SyncConflict diveTagConflict({ + String localTagName = 'Wreck', + bool tagMissing = false, + }) => SyncConflict( + entityType: 'diveTags', + recordId: '7600a6e8-42b8-4375-b71b-e492b9406adb', + localData: { + 'id': '7600a6e8-42b8-4375-b71b-e492b9406adb', + 'diveId': '889cb873-5517-41dc-8545-4bdb59307c38', + 'tagId': 'a7136f77-5628-4d6c-abaf-eed97f618cc8', + 'createdAt': 1786556582600, + }, + remoteData: { + 'id': '7600a6e8-42b8-4375-b71b-e492b9406adb', + 'diveId': '889cb873-5517-41dc-8545-4bdb59307c38', + 'tagId': 'b1234567-5628-4d6c-abaf-eed97f618cc8', + 'createdAt': 1786556582600, + }, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + localReferences: [ + ConflictReference( + field: 'diveId', + targetType: 'dives', + recordId: '889cb873-5517-41dc-8545-4bdb59307c38', + name: 'Blue Hole', + timestamp: diveDate, + ), + ConflictReference( + field: 'tagId', + targetType: 'tags', + recordId: 'a7136f77-5628-4d6c-abaf-eed97f618cc8', + name: tagMissing ? null : localTagName, + ), + ], + remoteReferences: [ + ConflictReference( + field: 'diveId', + targetType: 'dives', + recordId: '889cb873-5517-41dc-8545-4bdb59307c38', + name: 'Blue Hole', + timestamp: diveDate, + ), + const ConflictReference( + field: 'tagId', + targetType: 'tags', + recordId: 'b1234567-5628-4d6c-abaf-eed97f618cc8', + name: 'Night dive', + ), + ], + ); + + testWidgets('names the tag and the dive instead of printing their ids', ( + tester, + ) async { + await pumpDialog(tester, diveTagConflict()); + + expect(find.text('Tag:'), findsNWidgets(2)); + expect(find.text('Dive:'), findsNWidgets(2)); + expect(find.text('Wreck'), findsOneWidget); + expect(find.text('Night dive'), findsOneWidget); + // "Blue Hole (28/03/2026)": the dive is named by its site and dated. + // Both sides render one, on top of the composed header title. + expect(find.textContaining('Blue Hole ('), findsNWidgets(2)); + }); + + testWidgets('never shows a raw uuid or epoch millis in the preview', ( + tester, + ) async { + await pumpDialog(tester, diveTagConflict()); + + expect(find.textContaining('a7136f77'), findsNothing); + expect(find.textContaining('889cb873'), findsNothing); + expect(find.textContaining('1786556582600'), findsNothing); + }); + + testWidgets('says so when a referenced record is gone locally', ( + tester, + ) async { + await pumpDialog(tester, diveTagConflict(tagMissing: true)); + + expect(find.text('No longer in this library'), findsOneWidget); + }); + + testWidgets('describes the conflicting record in the header', (tester) async { + await pumpDialog(tester, diveTagConflict()); + + expect(find.text('Blue Hole \u2022 Wreck'), findsOneWidget); + expect(find.text('Dive Tags'), findsOneWidget); + expect(find.textContaining('7600a6e8'), findsNothing); + }); + + testWidgets('dates an epoch column but leaves a duration alone', ( + tester, + ) async { + // bottomTime and createdAt both end in a time-ish word, but bottomTime is + // seconds and createdAt is Unix millis. Only the magnitude tells them + // apart, so a naive name-only rule would date a 45-minute bottom time to + // 1970. + await pumpDialog( + tester, + SyncConflict( + entityType: 'dives', + recordId: 'd-1', + localData: const { + 'id': 'd-1', + 'diveNumber': 12, + 'bottomTime': 2700, + 'createdAt': 1786556582600, + }, + remoteData: const { + 'id': 'd-1', + 'diveNumber': 13, + 'bottomTime': 2700, + 'createdAt': 1786556582600, + }, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(find.text('2700'), findsNWidgets(2)); + expect(find.textContaining('1786556582600'), findsNothing); + }); + + testWidgets('renders a quality finding as its localized message', ( + tester, + ) async { + final finding = SyncConflict( + entityType: 'qualityFindings', + recordId: 'qf-1', + localData: const { + 'id': 'qf-1', + 'diveId': '889cb873-5517-41dc-8545-4bdb59307c38', + 'detectorId': 'depth_spike', + 'detectorVersion': 1, + 'category': 'profile', + 'severity': 'warning', + 'status': 'open', + 'params': '{"depth":42.0,"atSeconds":185}', + 'createdAt': 1786556582600, + 'updatedAt': 1786556582600, + }, + remoteData: const { + 'id': 'qf-1', + 'diveId': '889cb873-5517-41dc-8545-4bdb59307c38', + 'detectorId': 'depth_spike', + 'detectorVersion': 1, + 'category': 'profile', + 'severity': 'critical', + 'status': 'open', + 'params': '{"depth":42.0,"atSeconds":185}', + 'createdAt': 1786556582600, + 'updatedAt': 1786556582600, + }, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + localReferences: [ + ConflictReference( + field: 'diveId', + targetType: 'dives', + recordId: '889cb873-5517-41dc-8545-4bdb59307c38', + name: 'Blue Hole', + timestamp: diveDate, + ), + ], + remoteReferences: [ + ConflictReference( + field: 'diveId', + targetType: 'dives', + recordId: '889cb873-5517-41dc-8545-4bdb59307c38', + name: 'Blue Hole', + timestamp: diveDate, + ), + ], + ); + + await pumpDialog(tester, finding); + + expect(find.textContaining('Depth spike'), findsWidgets); + expect(find.textContaining('params'), findsNothing); + expect(find.textContaining('detectorId'), findsNothing); + }); +} From 78fc860468c3e8e3a0efcb60838ded36b45ac05f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:46:47 -0400 Subject: [PATCH 044/122] fix(sync): match sync entity types in the conflict dialog icon The icon lookup lowercased the entity type and compared against 'divesite' and 'dive_sites', but the sync layer names the entity 'diveSites', which lowercases to 'divesites' and matched neither. Gear had the same problem ('gear' vs 'equipment'), so both fell through to the generic document icon. Match the entity types the sync layer actually emits, keeping the legacy spellings, and give the junction and relation entities the dialog now describes an icon of their own. --- .../widgets/conflict_resolution_dialog.dart | 18 ++++++++++++++++++ .../conflict_resolution_dialog_test.dart | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart b/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart index fbd6f8ac82..d643c1ec87 100644 --- a/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart +++ b/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart @@ -392,22 +392,40 @@ class _ConflictResolutionDialogState } } + /// Icon for a sync entity type. Matched on the entity type the sync layer + /// actually uses (camelCase plurals such as `diveSites`), lowercased so the + /// legacy snake_case spellings keep working. IconData _getEntityIcon(String entityType) { switch (entityType.toLowerCase()) { case 'dive': case 'dives': return Icons.scuba_diving; case 'divesite': + case 'divesites': case 'dive_sites': return Icons.place; case 'gear': + case 'equipment': + case 'equipmentsets': return Icons.backpack; case 'diver': case 'divers': return Icons.person; + case 'buddies': + return Icons.people; case 'trip': case 'trips': return Icons.card_travel; + case 'tags': + case 'divetags': + return Icons.label; + case 'media': + return Icons.photo_library; + case 'species': + case 'sightings': + return Icons.pets; + case 'qualityfindings': + return Icons.rule; default: return Icons.description; } diff --git a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart index d1b080f7fe..98427b2a0f 100644 --- a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart +++ b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart @@ -132,6 +132,25 @@ void main() { expect(find.textContaining('7600a6e8'), findsNothing); }); + testWidgets('shows the entity icon for a sync entity type', (tester) async { + // The sync entity types are camelCase plurals ('diveSites'), which the + // icon lookup lowercases; matching only 'divesite'/'dive_sites' left + // sites and gear on the generic document icon. + await pumpDialog( + tester, + SyncConflict( + entityType: 'diveSites', + recordId: 's-1', + localData: const {'id': 's-1', 'name': 'Blue Hole'}, + remoteData: const {'id': 's-1', 'name': 'The Blue Hole'}, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(find.byIcon(Icons.place), findsOneWidget); + }); + testWidgets('dates an epoch column but leaves a duration alone', ( tester, ) async { From f0f39be6e9da708db45b83d3df64ec09986de2a3 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:48:46 -0400 Subject: [PATCH 045/122] fix(statistics): scope personal records to the active filter The Statistics tab's aggregate panels and Most Visited Sites both narrow when a filter is applied, because they read filteredDiveStatisticsProvider. Personal Records read diveRecordsProvider, whose getRecords() had no filter parameter at all, so the superlatives stayed lifetime-wide and contradicted the totals directly above them. getRecords now takes the same optional DiveFilterState that getStatistics takes, threading buildFilteredDiveIdSubquery into all seven superlative statements. Two clause shapes are needed: five already open with a WHERE, while firstDive/lastDive have none, so their scope clause has to open one. All seven bind the same vars list, so the diver placeholder is always emitted ahead of the filter placeholders. A new filteredDiveRecordsProvider watches statisticsFilterProvider and passes it through, split from diveRecordsProvider for the same reason filteredDiveStatisticsProvider is split from diveStatisticsProvider: the dive-log summary widget reads the unfiltered one and has no filter UI, so the Statistics scope must not reach it. Both Statistics-side surfaces use it: the records card on the overview page, and the full-page /records view reached from the tab's trophy action, which would otherwise have shown lifetime records one tap behind a filtered page. /records also hosts StatisticsFilterBar so the narrowed scope stays visible and clearable, and its empty state now distinguishes "no dives logged" from "no dives match your filters", reusing the existing diveLog_emptyFiltered_* strings rather than adding translations. Fixes #1028 --- .../repositories/dive_repository_impl.dart | 49 ++- .../presentation/pages/records_page.dart | 78 +++-- .../pages/statistics_overview_page.dart | 3 +- .../providers/statistics_providers.dart | 20 ++ .../provider_tick_build_smoke_test.dart | 4 + .../dive_records_filter_test.dart | 291 ++++++++++++++++++ .../presentation/pages/records_page_test.dart | 86 +++++- .../pages/statistics_overview_page_test.dart | 38 ++- .../filtered_dive_records_provider_test.dart | 111 +++++++ .../statistics_providers_all_test.dart | 8 + 10 files changed, 635 insertions(+), 53 deletions(-) create mode 100644 test/features/dive_log/data/repositories/dive_records_filter_test.dart create mode 100644 test/features/statistics/presentation/providers/filtered_dive_records_provider_test.dart diff --git a/lib/features/dive_log/data/repositories/dive_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_repository_impl.dart index 1b77552718..65e0e272f6 100644 --- a/lib/features/dive_log/data/repositories/dive_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_repository_impl.dart @@ -2681,21 +2681,48 @@ class DiveRepository { } /// Get dive records (superlatives) - /// Optionally filter by [diverId] for per-diver records - Future getRecords({String? diverId}) async { + /// + /// Optionally filter by [diverId] for per-diver records, and by [filter] for + /// a narrowed scope. Issue #1028: the Statistics tab shows these superlatives + /// beside totals that already honour its filter, so a deepest dive drawn from + /// the whole logbook contradicted the panel right above it. + Future getRecords({ + String? diverId, + DiveFilterState filter = const DiveFilterState(), + }) async { try { - final vars = diverId != null - ? [Variable(diverId)] - : >[]; + // Explicitly typed List>: a bare `[Variable(...)]` + // literal would reify as List>, and the later addAll of + // the filter binds would then throw (mirrors getStatistics). + final List> vars = [ + if (diverId != null) Variable(diverId), + ]; + final df = buildFilteredDiveIdSubquery(filter); + // params are always non-null so `p!` is safe. + vars.addAll(df.params.map((p) => Variable(p!))); + + // Every statement below binds the same `vars` list, so the diver `?` must + // always precede the filter `?`s -- hence the fixed clause order. final diverFilter = diverId != null ? 'AND d.diver_id = ?' : ''; - final diverFilterFirst = diverId != null ? 'WHERE d.diver_id = ?' : ''; + final filterClause = df.subquery.isEmpty + ? '' + : 'AND d.id IN (${df.subquery})'; + // The first/last statements have no WHERE of their own, so their scope + // clauses have to open one. + final scopeConditions = [ + if (diverId != null) 'd.diver_id = ?', + if (df.subquery.isNotEmpty) 'd.id IN (${df.subquery})', + ]; + final diverFilterFirst = scopeConditions.isEmpty + ? '' + : 'WHERE ${scopeConditions.join(' AND ')}'; // Deepest dive final deepestResult = await _db.customSelect(''' SELECT d.*, s.name as site_name FROM dives d LEFT JOIN dive_sites s ON d.site_id = s.id - WHERE d.max_depth IS NOT NULL $diverFilter + WHERE d.max_depth IS NOT NULL $diverFilter $filterClause ORDER BY d.max_depth DESC LIMIT 1 ''', variables: vars).getSingleOrNull(); @@ -2706,7 +2733,7 @@ class DiveRepository { COALESCE(d.runtime, d.bottom_time) as effective_runtime FROM dives d LEFT JOIN dive_sites s ON d.site_id = s.id - WHERE COALESCE(d.runtime, d.bottom_time) IS NOT NULL $diverFilter + WHERE COALESCE(d.runtime, d.bottom_time) IS NOT NULL $diverFilter $filterClause ORDER BY effective_runtime DESC LIMIT 1 ''', variables: vars).getSingleOrNull(); @@ -2716,7 +2743,7 @@ class DiveRepository { SELECT d.*, s.name as site_name FROM dives d LEFT JOIN dive_sites s ON d.site_id = s.id - WHERE d.water_temp IS NOT NULL $diverFilter + WHERE d.water_temp IS NOT NULL $diverFilter $filterClause ORDER BY d.water_temp ASC LIMIT 1 ''', variables: vars).getSingleOrNull(); @@ -2726,7 +2753,7 @@ class DiveRepository { SELECT d.*, s.name as site_name FROM dives d LEFT JOIN dive_sites s ON d.site_id = s.id - WHERE d.water_temp IS NOT NULL $diverFilter + WHERE d.water_temp IS NOT NULL $diverFilter $filterClause ORDER BY d.water_temp DESC LIMIT 1 ''', variables: vars).getSingleOrNull(); @@ -2756,7 +2783,7 @@ class DiveRepository { SELECT d.*, s.name as site_name FROM dives d LEFT JOIN dive_sites s ON d.site_id = s.id - WHERE d.max_depth IS NOT NULL AND d.max_depth > 0 $diverFilter + WHERE d.max_depth IS NOT NULL AND d.max_depth > 0 $diverFilter $filterClause ORDER BY d.max_depth ASC LIMIT 1 ''', variables: vars).getSingleOrNull(); diff --git a/lib/features/statistics/presentation/pages/records_page.dart b/lib/features/statistics/presentation/pages/records_page.dart index b14d737b60..5c6bd36388 100644 --- a/lib/features/statistics/presentation/pages/records_page.dart +++ b/lib/features/statistics/presentation/pages/records_page.dart @@ -4,16 +4,22 @@ import 'package:go_router/go_router.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; -import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_filter_provider.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; +import 'package:submersion/features/statistics/presentation/widgets/statistics_filter_bar.dart'; import 'package:submersion/l10n/l10n_extension.dart'; +/// Full-page personal records, reached from the Statistics tab's trophy +/// action. Scoped by the Statistics filter (issue #1028) so it agrees with the +/// records card on the overview page one tap behind it; the filter bar keeps +/// the narrowed scope visible and clearable here too. class RecordsPage extends ConsumerWidget { const RecordsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final recordsAsync = ref.watch(diveRecordsProvider); + final recordsAsync = ref.watch(filteredDiveRecordsProvider); return Scaffold( appBar: AppBar( @@ -22,32 +28,40 @@ class RecordsPage extends ConsumerWidget { IconButton( icon: const Icon(Icons.refresh), tooltip: context.l10n.statistics_tooltip_refreshRecords, - onPressed: () => ref.invalidate(diveRecordsProvider), + onPressed: () => ref.invalidate(filteredDiveRecordsProvider), ), ], ), - body: recordsAsync.when( - data: (records) => _buildContent(context, ref, records), - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 16), - Text(context.l10n.statistics_records_error), - const SizedBox(height: 8), - FilledButton( - onPressed: () => ref.invalidate(diveRecordsProvider), - child: Text(context.l10n.statistics_records_retry), + body: Column( + children: [ + const StatisticsFilterBar(), + Expanded( + child: recordsAsync.when( + data: (records) => _buildContent(context, ref, records), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text(context.l10n.statistics_records_error), + const SizedBox(height: 8), + FilledButton( + onPressed: () => + ref.invalidate(filteredDiveRecordsProvider), + child: Text(context.l10n.statistics_records_retry), + ), + ], + ), ), - ], + ), ), - ), + ], ), ); } @@ -67,12 +81,18 @@ class RecordsPage extends ConsumerWidget { records.warmestDive != null; if (!hasRecords) { + // "Start logging dives" is wrong advice when the logbook is full and the + // filter is simply too narrow, so the filtered case gets the dive list's + // wording instead. + final filtered = ref.watch( + statisticsFilterProvider.select((f) => f.hasActiveFilters), + ); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.emoji_events_outlined, + filtered ? Icons.filter_list_off : Icons.emoji_events_outlined, size: 80, color: Theme.of( context, @@ -80,15 +100,21 @@ class RecordsPage extends ConsumerWidget { ), const SizedBox(height: 16), Text( - context.l10n.statistics_records_emptyTitle, + filtered + ? context.l10n.diveLog_emptyFiltered_title + : context.l10n.statistics_records_emptyTitle, style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( - context.l10n.statistics_records_emptySubtitle, + filtered + ? context.l10n.diveLog_emptyFiltered_subtitle + : context.l10n.statistics_records_emptySubtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), + textAlign: TextAlign.center, ), ], ), diff --git a/lib/features/statistics/presentation/pages/statistics_overview_page.dart b/lib/features/statistics/presentation/pages/statistics_overview_page.dart index df773431c6..6e2a0017e6 100644 --- a/lib/features/statistics/presentation/pages/statistics_overview_page.dart +++ b/lib/features/statistics/presentation/pages/statistics_overview_page.dart @@ -5,7 +5,6 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; -import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_log/presentation/widgets/add_dive_bottom_sheet.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -81,7 +80,7 @@ class _OverviewBody extends ConsumerWidget { final settings = ref.watch(settingsProvider); final fmt = UnitFormatter(settings); - final recordsAsync = ref.watch(diveRecordsProvider); + final recordsAsync = ref.watch(filteredDiveRecordsProvider); return SingleChildScrollView( padding: const EdgeInsets.all(16), diff --git a/lib/features/statistics/presentation/providers/statistics_providers.dart b/lib/features/statistics/presentation/providers/statistics_providers.dart index d243041235..402ae90ca6 100644 --- a/lib/features/statistics/presentation/providers/statistics_providers.dart +++ b/lib/features/statistics/presentation/providers/statistics_providers.dart @@ -33,6 +33,26 @@ final filteredDiveStatisticsProvider = FutureProvider(( return repository.getStatistics(diverId: currentDiverId, filter: filter); }); +/// Personal records (superlatives) scoped by the Statistics filter. +/// +/// Split from diveRecordsProvider for the same reason +/// [filteredDiveStatisticsProvider] is split from diveStatisticsProvider: the +/// dive-log summary widget reads the unfiltered one and has no filter UI, so +/// the Statistics tab's scope must not reach it. Issue #1028: before this +/// split, the Statistics tab's records were the only panel on the page that +/// ignored the filter. +/// +/// Takes the same dives tick as its unfiltered sibling (issue #217): a merge, +/// a bulk delete, or a sync pull rewrites the superlatives without going +/// through any notifier. +final filteredDiveRecordsProvider = FutureProvider((ref) async { + final repository = ref.watch(diveRepositoryProvider); + final currentDiverId = ref.watch(currentDiverIdProvider); + final filter = ref.watch(statisticsFilterProvider); + ref.invalidateSelfWhen(repository.watchDivesChanges()); + return repository.getRecords(diverId: currentDiverId, filter: filter); +}); + /// Adds keepAlive with a 5-minute expiry and subscribes to the statistics /// change tick, so all stats providers stay cached across navigations but /// refresh whenever any table they read is written. diff --git a/test/architecture/provider_tick_build_smoke_test.dart b/test/architecture/provider_tick_build_smoke_test.dart index c266864c3f..b8910a6371 100644 --- a/test/architecture/provider_tick_build_smoke_test.dart +++ b/test/architecture/provider_tick_build_smoke_test.dart @@ -767,6 +767,10 @@ void main() { name: 'filteredDiveStatisticsProvider', read: (c) => c.read(filteredDiveStatisticsProvider.future), ), + ( + name: 'filteredDiveRecordsProvider', + read: (c) => c.read(filteredDiveRecordsProvider.future), + ), ]); _tickGroup('tags', [ diff --git a/test/features/dive_log/data/repositories/dive_records_filter_test.dart b/test/features/dive_log/data/repositories/dive_records_filter_test.dart new file mode 100644 index 0000000000..104f739992 --- /dev/null +++ b/test/features/dive_log/data/repositories/dive_records_filter_test.dart @@ -0,0 +1,291 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1028: the Statistics tab's personal records ignored the active +/// Statistics filter while every other panel on the page honoured it. +void main() { + late AppDatabase db; + late DiveRepository repository; + + setUp(() async { + db = await setUpTestDatabase(); + repository = DiveRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + final now = DateTime(2026, 6, 1).millisecondsSinceEpoch; + + Future insertSite(String id) async { + await db + .into(db.diveSites) + .insert( + DiveSitesCompanion( + id: Value(id), + name: Value('Site $id'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + Future insertDiver(String id) async { + await db + .into(db.divers) + .insert( + DiversCompanion( + id: Value(id), + name: Value('Diver $id'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + Future insertDive( + String id, { + required DateTime date, + String? diverId, + String? siteId, + double? maxDepth, + double? waterTemp, + int? bottomTimeSeconds, + bool favorite = false, + }) async { + await db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diverId: Value(diverId), + diveDateTime: Value(date.millisecondsSinceEpoch), + siteId: Value(siteId), + maxDepth: Value(maxDepth), + waterTemp: Value(waterTemp), + bottomTime: Value(bottomTimeSeconds), + isFavorite: Value(favorite), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + group('getRecords with a filter', () { + test('an empty filter still considers every dive', () async { + await insertDive( + 'shallow', + date: DateTime(2024, 1, 10), + maxDepth: 12, + waterTemp: 26, + bottomTimeSeconds: 1800, + ); + await insertDive( + 'deep', + date: DateTime(2026, 5, 10), + maxDepth: 44, + waterTemp: 9, + bottomTimeSeconds: 3600, + ); + + final records = await repository.getRecords(); + + expect(records.deepestDive!.diveId, 'deep'); + expect(records.shallowestDive!.diveId, 'shallow'); + expect(records.longestDive!.diveId, 'deep'); + expect(records.coldestDive!.diveId, 'deep'); + expect(records.warmestDive!.diveId, 'shallow'); + expect(records.firstDive!.diveId, 'shallow'); + expect(records.lastDive!.diveId, 'deep'); + }); + + test('a date-range filter narrows every superlative', () async { + await insertDive( + 'old', + date: DateTime(2024, 1, 10), + maxDepth: 60, + waterTemp: 4, + bottomTimeSeconds: 7200, + ); + await insertDive( + 'inRange', + date: DateTime(2026, 6, 15), + maxDepth: 30, + waterTemp: 20, + bottomTimeSeconds: 2400, + ); + await insertDive( + 'newer', + date: DateTime(2026, 9, 1), + maxDepth: 50, + waterTemp: 8, + bottomTimeSeconds: 5400, + ); + + final records = await repository.getRecords( + filter: DiveFilterState( + startDate: DateTime(2026, 6, 1), + endDate: DateTime(2026, 6, 30), + ), + ); + + expect(records.deepestDive!.diveId, 'inRange'); + expect(records.shallowestDive!.diveId, 'inRange'); + expect(records.longestDive!.diveId, 'inRange'); + expect(records.coldestDive!.diveId, 'inRange'); + expect(records.warmestDive!.diveId, 'inRange'); + expect(records.firstDive!.diveId, 'inRange'); + expect(records.lastDive!.diveId, 'inRange'); + }); + + test('a site filter narrows the records to that site', () async { + await insertSite('reef'); + await insertSite('wreck'); + await insertDive( + 'atReef', + date: DateTime(2026, 3, 1), + siteId: 'reef', + maxDepth: 18, + waterTemp: 24, + ); + await insertDive( + 'atWreck', + date: DateTime(2026, 4, 1), + siteId: 'wreck', + maxDepth: 42, + waterTemp: 12, + ); + + final records = await repository.getRecords( + filter: const DiveFilterState(siteId: 'wreck'), + ); + + expect(records.deepestDive!.diveId, 'atWreck'); + expect(records.warmestDive!.diveId, 'atWreck'); + expect(records.firstDive!.diveId, 'atWreck'); + expect(records.lastDive!.diveId, 'atWreck'); + }); + + test('a filter that matches nothing yields no records', () async { + await insertDive( + 'only', + date: DateTime(2026, 3, 1), + maxDepth: 18, + waterTemp: 24, + bottomTimeSeconds: 1800, + ); + + final records = await repository.getRecords( + filter: const DiveFilterState(favoritesOnly: true), + ); + + expect(records.deepestDive, isNull); + expect(records.shallowestDive, isNull); + expect(records.longestDive, isNull); + expect(records.coldestDive, isNull); + expect(records.warmestDive, isNull); + expect(records.firstDive, isNull); + expect(records.lastDive, isNull); + }); + + test('the diver scope and the filter compose', () async { + await insertDiver('me'); + await insertDiver('other'); + await insertDive( + 'mineFavorite', + date: DateTime(2026, 3, 1), + diverId: 'me', + maxDepth: 20, + waterTemp: 22, + bottomTimeSeconds: 1800, + favorite: true, + ); + await insertDive( + 'minePlain', + date: DateTime(2026, 3, 2), + diverId: 'me', + maxDepth: 55, + waterTemp: 6, + bottomTimeSeconds: 3600, + ); + await insertDive( + 'theirsFavorite', + date: DateTime(2026, 3, 3), + diverId: 'other', + maxDepth: 70, + waterTemp: 3, + bottomTimeSeconds: 5400, + favorite: true, + ); + + final records = await repository.getRecords( + diverId: 'me', + filter: const DiveFilterState(favoritesOnly: true), + ); + + expect(records.deepestDive!.diveId, 'mineFavorite'); + expect(records.shallowestDive!.diveId, 'mineFavorite'); + expect(records.longestDive!.diveId, 'mineFavorite'); + expect(records.coldestDive!.diveId, 'mineFavorite'); + expect(records.warmestDive!.diveId, 'mineFavorite'); + expect(records.firstDive!.diveId, 'mineFavorite'); + expect(records.lastDive!.diveId, 'mineFavorite'); + }); + + // The no-buddy axis is the one clause that names `dives` explicitly inside + // its own correlated subquery; getRecords aliases the outer table as `d`, + // so this guards against the alias resolving the wrong way. + test( + 'a no-buddy filter resolves inside the aliased records query', + () async { + await insertDive( + 'solo', + date: DateTime(2026, 3, 1), + maxDepth: 20, + waterTemp: 22, + bottomTimeSeconds: 1800, + ); + await insertDive( + 'paired', + date: DateTime(2026, 3, 2), + maxDepth: 40, + waterTemp: 10, + bottomTimeSeconds: 3600, + ); + await db + .into(db.buddies) + .insert( + BuddiesCompanion( + id: const Value('b1'), + name: const Value('Buddy One'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + await db + .into(db.diveBuddies) + .insert( + DiveBuddiesCompanion( + id: const Value('paired-b1'), + diveId: const Value('paired'), + buddyId: const Value('b1'), + createdAt: Value(now), + ), + ); + + final records = await repository.getRecords( + filter: const DiveFilterState(noBuddyOnly: true), + ); + + expect(records.deepestDive!.diveId, 'solo'); + expect(records.lastDive!.diveId, 'solo'); + }, + ); + }); +} diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index 74e46ee91e..30a126edda 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -11,7 +11,6 @@ import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/deco/entities/cns_calculation_method.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; -import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/core/constants/card_color.dart'; import 'package:submersion/core/constants/map_style.dart'; @@ -24,6 +23,10 @@ import 'package:submersion/core/utils/coordinates/coordinate_format.dart'; import 'package:submersion/features/dive_3d/domain/spatial/seascape_appearance.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/statistics/presentation/pages/records_page.dart'; +import 'package:submersion/features/statistics/presentation/widgets/statistics_filter_bar.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_filter_provider.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; typedef Override = riverpod.Override; @@ -497,14 +500,29 @@ void main() { prefs = await SharedPreferences.getInstance(); }); - /// Helper to create common provider overrides + /// Helper to create common provider overrides. + /// + /// [filter] drives the Statistics scope the page now follows (issue + /// #1028); an active one also makes StatisticsFilterBar read the filtered + /// statistics, hence the paired override. List getOverrides({ Future Function(Ref)? diveRecordsOverride, + DiveFilterState filter = const DiveFilterState(), }) { return [ - diveRecordsProvider.overrideWith( + filteredDiveRecordsProvider.overrideWith( diveRecordsOverride ?? (ref) async => DiveRecords(), ), + statisticsFilterProvider.overrideWith((ref) => filter), + filteredDiveStatisticsProvider.overrideWith( + (ref) async => DiveStatistics( + totalDives: 0, + totalTimeSeconds: 0, + maxDepth: 0, + avgMaxDepth: 0, + totalSites: 0, + ), + ), sharedPreferencesProvider.overrideWithValue(prefs), // Mock the settingsProvider to avoid database access settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -552,6 +570,68 @@ void main() { ); }); + // Issue #1028: the page follows the Statistics filter, so an empty result + // can mean "the filter is too narrow" rather than "no dives logged". + testWidgets('shows the filtered empty state when a filter is active', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: getOverrides( + filter: const DiveFilterState(favoritesOnly: true), + ), + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No dives match your filters'), findsOneWidget); + expect(find.text('No Records Yet'), findsNothing); + }); + + testWidgets('hosts the filter bar, collapsed while no filter is active', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: getOverrides(), + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(StatisticsFilterBar), findsOneWidget); + expect(find.byIcon(Icons.filter_list), findsNothing); + }); + + testWidgets('shows the filter bar summary while a filter is active', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: getOverrides( + filter: const DiveFilterState(favoritesOnly: true), + ), + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.filter_list), findsOneWidget); + }); + testWidgets('should display refresh button in app bar', (tester) async { await tester.pumpWidget( ProviderScope( diff --git a/test/features/statistics/presentation/pages/statistics_overview_page_test.dart b/test/features/statistics/presentation/pages/statistics_overview_page_test.dart index ada630ef8e..3f29bbb34e 100644 --- a/test/features/statistics/presentation/pages/statistics_overview_page_test.dart +++ b/test/features/statistics/presentation/pages/statistics_overview_page_test.dart @@ -62,7 +62,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => fixture), filteredDiveStatisticsProvider.overrideWith((ref) async => fixture), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -126,7 +128,7 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => records), + filteredDiveRecordsProvider.overrideWith((ref) async => records), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -179,7 +181,7 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => records), + filteredDiveRecordsProvider.overrideWith((ref) async => records), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -252,7 +254,7 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => records), + filteredDiveRecordsProvider.overrideWith((ref) async => records), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -304,7 +306,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -342,7 +346,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -401,7 +407,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -467,7 +475,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -521,7 +531,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -579,7 +591,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => diveTypes), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), @@ -615,7 +629,9 @@ void main() { overrides: [ diveStatisticsProvider.overrideWith((ref) async => stats), filteredDiveStatisticsProvider.overrideWith((ref) async => stats), - diveRecordsProvider.overrideWith((ref) async => DiveRecords()), + filteredDiveRecordsProvider.overrideWith( + (ref) async => DiveRecords(), + ), diveTypeDistributionProvider.overrideWith((ref) async => []), sharedPreferencesProvider.overrideWithValue(prefs), settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), diff --git a/test/features/statistics/presentation/providers/filtered_dive_records_provider_test.dart b/test/features/statistics/presentation/providers/filtered_dive_records_provider_test.dart new file mode 100644 index 0000000000..f8ee5509f3 --- /dev/null +++ b/test/features/statistics/presentation/providers/filtered_dive_records_provider_test.dart @@ -0,0 +1,111 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/gas_model.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_filter_provider.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +/// Issue #1028: personal records on the Statistics tab must follow the tab's +/// filter, the way every other panel on that page already does. The unfiltered +/// [diveRecordsProvider] stays as-is for the dive-log summary widget, which is +/// not a Statistics-tab surface. +void main() { + late AppDatabase db; + + setUp(() async { + db = await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + final now = DateTime(2026, 6, 1).millisecondsSinceEpoch; + + Future insertDive(String id, {required double maxDepth}) async { + await db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: Value(now), + maxDepth: Value(maxDepth), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + } + + ProviderContainer makeContainer(DiveFilterState filter) => ProviderContainer( + overrides: [ + currentDiverIdProvider.overrideWith( + (ref) => MockCurrentDiverIdNotifier(), + ), + gasModelProvider.overrideWith((ref) => GasModel.real), + statisticsFilterProvider.overrideWith((ref) => filter), + ], + ); + + test('filteredDiveRecordsProvider narrows the records to the active ' + 'Statistics filter', () async { + await insertDive('shallow', maxDepth: 10); + await insertDive('deep', maxDepth: 40); + + final unfilteredContainer = makeContainer(const DiveFilterState()); + addTearDown(unfilteredContainer.dispose); + final unfiltered = await unfilteredContainer.read( + filteredDiveRecordsProvider.future, + ); + + // Sanity check: without a filter the deep dive wins, so a filter that + // excludes it has something real to change. + expect(unfiltered.deepestDive!.diveId, 'deep'); + + final filteredContainer = makeContainer( + const DiveFilterState(maxDepth: 20), + ); + addTearDown(filteredContainer.dispose); + final filtered = await filteredContainer.read( + filteredDiveRecordsProvider.future, + ); + + expect( + filtered.deepestDive!.diveId, + 'shallow', + reason: + 'the deepest dive must come from the filtered subset, otherwise it ' + 'contradicts the totals shown directly above it', + ); + expect(filtered.shallowestDive!.diveId, 'shallow'); + expect(filtered.firstDive!.diveId, 'shallow'); + expect(filtered.lastDive!.diveId, 'shallow'); + }); + + test( + 'diveRecordsProvider stays unfiltered for non-Statistics surfaces', + () async { + await insertDive('shallow', maxDepth: 10); + await insertDive('deep', maxDepth: 40); + + final container = makeContainer(const DiveFilterState(maxDepth: 20)); + addTearDown(container.dispose); + + final records = await container.read(diveRecordsProvider.future); + + expect( + records.deepestDive!.diveId, + 'deep', + reason: + 'the dive-log summary widget reads diveRecordsProvider and has no ' + 'filter UI of its own; the Statistics filter must not reach it', + ); + }, + ); +} diff --git a/test/features/statistics/presentation/providers/statistics_providers_all_test.dart b/test/features/statistics/presentation/providers/statistics_providers_all_test.dart index dc205ae75e..a453f13169 100644 --- a/test/features/statistics/presentation/providers/statistics_providers_all_test.dart +++ b/test/features/statistics/presentation/providers/statistics_providers_all_test.dart @@ -50,6 +50,10 @@ void main() { (await container.read(filteredDiveStatisticsProvider.future)).totalDives, 0, ); + expect( + (await container.read(filteredDiveRecordsProvider.future)).deepestDive, + isNull, + ); expect(await container.read(gasMixDistributionProvider.future), isEmpty); expect(await container.read(diveTypeDistributionProvider.future), isEmpty); expect(await container.read(depthProgressionTrendProvider.future), isEmpty); @@ -132,6 +136,10 @@ void main() { (await container.read(filteredDiveStatisticsProvider.future)).totalDives, 0, ); + expect( + (await container.read(filteredDiveRecordsProvider.future)).deepestDive, + isNull, + ); expect(await container.read(topBuddiesProvider.future), isEmpty); expect(await container.read(divesPerYearProvider.future), isEmpty); }); From 740ba39b53eb313e6126c10184e7b2ea0d665507 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:49:44 -0400 Subject: [PATCH 046/122] docs(sites): implementation plan for location details from coordinates (#1187) --- ...26-08-26-site-location-from-coordinates.md | 3991 +++++++++++++++++ 1 file changed, 3991 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md diff --git a/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md b/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md new file mode 100644 index 0000000000..dbc2788660 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md @@ -0,0 +1,3991 @@ +# Site Location From Coordinates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fill town and body of water (not only country and region) from a dive site's coordinates, in a synced per-diver language, with a per-site "Look up from coordinates" action and a bulk "Fill in missing location details" pass that never overwrites existing values. + +**Architecture:** `LocationService.reverseGeocode` returns a `PlaceLookup` value and takes a language code; body of water comes from a second Nominatim request on the `natural` layer, filtered to water features, behind a shared one-request-per-second throttle. A new `diver_settings.place_name_language` column (v162) feeds every caller through `placeNameLanguageProvider`. The site form gains one shared fill-empty routine and a lookup button; a `SiteLocationBackfillService` walks sites through a column-patching repository method. + +**Tech Stack:** Flutter, Riverpod (StateNotifier), Drift, `dart:io HttpClient` against Nominatim, `geocoding` 5.x, `clock` + `fake_async` for time, `flutter gen-l10n` ARB localisation. + +**Spec:** `docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md` + +## Global Constraints + +- Worktree: `.claude/worktrees/issue-1187-site-geocoding`, branch `worktree-issue-1187-site-geocoding`. Every shell command below must start with `cd /Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/issue-1187-site-geocoding && echo "PWD: $(pwd)" &&` (the Bash cwd silently resets between calls; a relative write in the wrong tree edits main). +- Never use an em-dash or en-dash as punctuation in code, comments, docs, strings, or commit messages. Use commas, colons, or two sentences. +- No emojis in code or comments. +- TDD: write the failing test, run it and watch it fail, implement, run it and watch it pass, then commit. +- `dart format .` before every commit. `flutter analyze` must report "No issues found" (infos are fatal in CI). +- Every new user-visible string goes into all 11 ARB files: `ar, de, en, es, fr, he, hu, it, nl, pt, zh` under `lib/l10n/arb/`. Regenerate with `flutter gen-l10n`. Placeholder `@key` metadata goes in `app_en.arb` (run `grep -c '"@' lib/l10n/arb/app_de.arb` once; if it is non-zero, mirror the metadata there too). +- Schema version for the new column: 162. If `grep -n "currentSchemaVersion = " lib/core/database/database.dart` already shows 162 or higher when Task 4 starts, use the next free number everywhere the plan says 162 (constant, ladder list, helper comment, test file name, sync defaults comment). +- Place name language default is `'en'`. Supported codes: `en, es, fr, de, it, nl, pt, hu, ar, he, zh`. There is no "follow app language" mode. +- Body of water is accepted only from Nominatim hits with `class == 'water'`, or `class == 'natural'` with `type` in `{bay, strait}`. +- The bulk pass writes only empty columns. Only the per-site Replace dialog may overwrite. +- The site save path never geocodes (existing Grand Turk / Bonaire tests in `test/features/dive_sites/presentation/pages/site_edit_page_test.dart` must keep passing unchanged). +- Do not push. Commits are local to the worktree branch. + +--- + +## File Structure + +**Create** +- `lib/core/services/geocoding/place_lookup.dart`: `PlaceLookup` value (country, region, locality, bodyOfWater, networkFailed). +- `lib/core/services/geocoding/nominatim_throttle.dart`: `NominatimThrottle`, serialises Nominatim requests one second apart using `clock.now()`. +- `lib/core/constants/place_name_language.dart`: `PlaceNameLanguage.supportedCodes`, `defaultCode`, `normalize`. +- `lib/features/settings/presentation/widgets/place_name_language_picker.dart`: `showPlaceNameLanguagePicker`, `PlaceNameLanguageList`, `placeNameLanguageLabel`. +- `lib/features/dive_sites/domain/services/site_location_merge.dart`: `SiteLocationDetails` and `mergeMissingLocationDetails`. +- `lib/features/dive_sites/domain/services/site_location_backfill_service.dart`: `SiteLocationBackfillService`, `BackfillSummary`. +- `lib/features/dive_sites/presentation/providers/site_location_backfill_provider.dart`: `BackfillState`, `SiteLocationBackfillNotifier`, `siteLocationBackfillProvider`. +- `lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart`: confirmation, progress dialog, summary snackbar. +- Tests listed per task. + +**Modify** +- `lib/core/services/location_service.dart`: language parameter, `PlaceLookup` return, natural-layer lookup, throttle. +- `lib/core/database/database.dart`: column, v162 helper and ladder step. +- `lib/core/services/sync/sync_data_serializer.dart`: older-peer default. +- `lib/features/settings/presentation/providers/settings_providers.dart`, `lib/features/settings/data/repositories/diver_settings_repository.dart`, `lib/features/settings/presentation/pages/settings_page.dart`, `lib/features/settings/presentation/pages/language_settings_page.dart`. +- `lib/features/dive_sites/presentation/pages/site_edit_page.dart`, `lib/features/dive_sites/presentation/widgets/edit_sections/location_section.dart`, `lib/features/dive_sites/presentation/widgets/location_picker_map.dart`, `lib/features/dive_sites/presentation/pages/site_list_page.dart`, `lib/features/dive_sites/presentation/widgets/site_list_content.dart` (if it carries the sites overflow menu), `lib/features/dive_sites/data/repositories/site_repository_impl.dart`. +- `lib/features/maps/presentation/widgets/region_download_dialog.dart`, `lib/features/dive_import/data/services/uddf_entity_importer.dart`, `lib/features/import_wizard/data/adapters/universal_adapter.dart`, `test/integration/uddf_test_importer.dart`. +- All 11 ARB files. + +--- + +### Task 1: `PlaceLookup` and the language parameter + +**Files:** +- Create: `lib/core/services/geocoding/place_lookup.dart` +- Modify: `lib/core/services/location_service.dart` (lines 14-35 `LocationResult`, 39-63 URI builders and locale pin, 101-222 `getCurrentLocation`, 224-313 `reverseGeocode` and `_reverseGeocodeWeb`) +- Modify callers so the tree compiles, all passing `languageCode: LocationService.defaultLanguageCode` for now (Task 7 wires the real setting): `lib/features/dive_sites/presentation/pages/site_edit_page.dart:212-215`, `lib/features/dive_sites/presentation/widgets/location_picker_map.dart:64-67` and `:99-102`, `lib/features/maps/presentation/widgets/region_download_dialog.dart:71-74`, `lib/features/dive_import/data/services/uddf_entity_importer.dart:1035-1038` and `:1108-1111`, `test/integration/uddf_test_importer.dart:442`, `test/features/dive_sites/presentation/pages/site_edit_page_test.dart:31-58` (`_FakeLocationService`), plus any other fake found by `grep -rn "reverseGeocode(" test/`. +- Test: `test/core/services/location_service_test.dart` + +**Interfaces:** +- Produces: `class PlaceLookup { const PlaceLookup({String? country, String? region, String? locality, String? bodyOfWater, bool networkFailed = false}); const PlaceLookup.empty(); const PlaceLookup.unavailable(); bool get isEmpty; }` +- Produces: `Future LocationService.reverseGeocode(double latitude, double longitude, {required String languageCode})` +- Produces: `static const String LocationService.defaultLanguageCode = 'en'` +- Produces: `static Uri LocationService.buildReverseGeocodeUri(double latitude, double longitude, {required String languageCode})` +- Produces: `Future LocationService.getCurrentLocation({bool includeGeocoding = true, Duration timeout = const Duration(seconds: 15), String languageCode = LocationService.defaultLanguageCode})`; `LocationResult` gains `final String? bodyOfWater` and `PlaceLookup get place`. + +- [ ] **Step 1: Write the failing tests** + +Append to the `reverseGeocode web fallback` group in `test/core/services/location_service_test.dart` (after the test named `'sends the English pin in both the URI and the request headers'`), and change every existing `service.reverseGeocode(a, b)` call in the file to `service.reverseGeocode(a, b, languageCode: 'en')`: + +```dart + test('sends the requested language in the URI and the headers', () async { + final server = _FakeNominatim( + body: jsonEncode({ + 'address': {'country': 'Schweiz'}, + }), + ); + + final result = await server.run( + () => service.reverseGeocode(47.0276, 8.4006, languageCode: 'de'), + ); + + expect(result.country, 'Schweiz'); + expect(server.lastUri.queryParameters['accept-language'], 'de'); + expect(server.lastHeaders['accept-language'], 'de'); + }); + + test('returns PlaceLookup.unavailable when the request throws', () async { + final result = await HttpOverrides.runZoned( + () => service.reverseGeocode(47.0, 8.4, languageCode: 'en'), + createHttpClient: (_) => _ThrowingHttpClient(), + ); + + expect(result.isEmpty, isTrue); + expect(result.networkFailed, isTrue); + }); +``` + +Add this fake next to `_FakeHttpClient`: + +```dart +class _ThrowingHttpClient implements HttpClient { + @override + String? userAgent; + + @override + Future getUrl(Uri url) async { + throw const SocketException('offline'); + } + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} +``` + +In the `native geocoder locale (#214)` group, change the test `'asks the geocoder for English results'` to call `service.reverseGeocode(36.0143, -5.6044, languageCode: 'es')` and expect `geocoding.locales` to equal `[const Locale('es')]`; keep the other two tests passing `languageCode: 'en'`. + +In the `Nominatim URIs pin English results (#214)` group, change the reverse URI test to call `LocationService.buildReverseGeocodeUri(36.0, -5.6, languageCode: 'fr')` and expect `accept-language` to be `'fr'`. + +- [ ] **Step 2: Run the test file to verify it fails** + +Run: `flutter test test/core/services/location_service_test.dart` +Expected: compilation errors: `languageCode` is not a named parameter, `PlaceLookup`/`networkFailed` undefined. + +- [ ] **Step 3: Create `PlaceLookup`** + +`lib/core/services/geocoding/place_lookup.dart`: + +```dart +/// What a reverse geocode of one coordinate found. +/// +/// Every field is optional: a point in open sea has no locality, a point on +/// land has no body of water. [networkFailed] is true when the lookup could +/// not reach the geocoder at all, so a caller iterating many sites can stop +/// early instead of collecting one failure per site. +class PlaceLookup { + const PlaceLookup({ + this.country, + this.region, + this.locality, + this.bodyOfWater, + this.networkFailed = false, + }); + + const PlaceLookup.empty() : this(); + + const PlaceLookup.unavailable() : this(networkFailed: true); + + final String? country; + final String? region; + final String? locality; + final String? bodyOfWater; + final bool networkFailed; + + bool get isEmpty => + country == null && + region == null && + locality == null && + bodyOfWater == null; + + PlaceLookup copyWith({String? bodyOfWater}) => PlaceLookup( + country: country, + region: region, + locality: locality, + bodyOfWater: bodyOfWater ?? this.bodyOfWater, + networkFailed: networkFailed, + ); + + @override + String toString() => + 'PlaceLookup(country: $country, region: $region, locality: $locality, ' + 'bodyOfWater: $bodyOfWater, networkFailed: $networkFailed)'; +} +``` + +- [ ] **Step 4: Thread the language through `LocationService`** + +In `lib/core/services/location_service.dart`: + +Add `import 'dart:io' show Platform, HttpClient, SocketException;` (replace the existing `dart:io` import) and `import 'package:submersion/core/services/geocoding/place_lookup.dart';`. + +Replace `LocationResult` (lines 14-35) with: + +```dart +/// Result of a location capture +class LocationResult { + final double latitude; + final double longitude; + final double? accuracy; + final String? country; + final String? region; + final String? locality; + final String? bodyOfWater; + + const LocationResult({ + required this.latitude, + required this.longitude, + this.accuracy, + this.country, + this.region, + this.locality, + this.bodyOfWater, + }); + + /// The geocoded part of this result, in the shape the site form consumes. + PlaceLookup get place => PlaceLookup( + country: country, + region: region, + locality: locality, + bodyOfWater: bodyOfWater, + ); + + @override + String toString() => + 'LocationResult(lat: $latitude, lng: $longitude, country: $country, region: $region)'; +} +``` + +Replace the URI builder and locale pin block (lines 39-63) with: + +```dart + /// The language every existing row was geocoded in. Issue #214 pinned + /// results to English because the platform geocoder answered in the device + /// locale and split one country across 'Spanien' and 'España'. The pin is + /// now a synced per-diver setting (issue #1187) whose default is this + /// value, so unchanged users keep grouping exactly as before. + static const String defaultLanguageCode = 'en'; + + /// Nominatim reverse-geocode URI for the address layer. + static Uri buildReverseGeocodeUri( + double latitude, + double longitude, { + required String languageCode, + }) => Uri.parse( + 'https://nominatim.openstreetmap.org/reverse?format=json' + '&lat=$latitude&lon=$longitude&zoom=10&accept-language=$languageCode', + ); + + /// Nominatim forward-geocode URI, English-pinned: dive centres are matched + /// by address text, not grouped in statistics. + static Uri buildForwardGeocodeUri(String address) => Uri.parse( + 'https://nominatim.openstreetmap.org/search?format=json' + '&q=${Uri.encodeComponent(address)}&limit=1&addressdetails=1' + '&accept-language=$defaultLanguageCode', + ); +``` + +Delete the `_geocoderLocale` constant and its comment. Keep `debugForceNativeGeocoder` and `_useNativeGeocoder` as they are. + +Change `getCurrentLocation`'s signature to: + +```dart + Future getCurrentLocation({ + bool includeGeocoding = true, + Duration timeout = const Duration(seconds: 15), + String languageCode = defaultLanguageCode, + }) async { +``` + +and its geocoding block (lines 191-213) to: + +```dart + PlaceLookup place = const PlaceLookup.empty(); + + // Perform reverse geocoding if requested + if (includeGeocoding) { + place = await reverseGeocode( + position.latitude, + position.longitude, + languageCode: languageCode, + ); + } + + return LocationResult( + latitude: position.latitude, + longitude: position.longitude, + accuracy: position.accuracy, + country: place.country, + region: place.region, + locality: place.locality, + bodyOfWater: place.bodyOfWater, + ); +``` + +Replace `reverseGeocode` and `_reverseGeocodeWeb` (lines 224-313) with: + +```dart + /// Reverse geocode a coordinate into country, region and locality, in the + /// language named by [languageCode] (an ISO 639-1 code such as 'en'). + /// + /// Uses the platform geocoder on mobile and falls back to OpenStreetMap + /// Nominatim everywhere else. Never throws: a geocoder that cannot be + /// reached yields [PlaceLookup.unavailable]. + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async { + try { + _log.info('Reverse geocoding: $latitude, $longitude ($languageCode)'); + + // Try native geocoding first (works on iOS/Android) + if (_useNativeGeocoder) { + try { + // Built per call rather than cached in a static: construction only + // asks the platform factory for an implementation, and a cached + // instance would outlive the per-test factory fakes. + final placemarks = await Geocoding().placemarkFromCoordinates( + latitude, + longitude, + locale: Locale(languageCode), + ); + if (placemarks.isNotEmpty) { + final place = placemarks.first; + _log.info( + 'Native geocoded: ${place.locality}, ${place.administrativeArea}, ${place.country}', + ); + return PlaceLookup( + country: place.country, + region: place.administrativeArea, + locality: place.locality, + ); + } + } catch (e) { + _log.warning('Native geocoding failed, trying web fallback: $e'); + } + } + + // Fallback to OpenStreetMap Nominatim API (works on all platforms) + return await _reverseGeocodeWeb(latitude, longitude, languageCode); + } catch (e, stackTrace) { + _log.error('Reverse geocoding failed', error: e, stackTrace: stackTrace); + return const PlaceLookup.unavailable(); + } + } + + /// Web-based reverse geocoding using OpenStreetMap Nominatim + Future _reverseGeocodeWeb( + double latitude, + double longitude, + String languageCode, + ) async { + try { + final json = await _fetchNominatimJson( + buildReverseGeocodeUri( + latitude, + longitude, + languageCode: languageCode, + ), + languageCode, + ); + final address = json?['address'] as Map?; + if (address == null) return const PlaceLookup.empty(); + + final country = address['country'] as String?; + final region = + address['state'] as String? ?? + address['province'] as String? ?? + address['region'] as String?; + final locality = + address['city'] as String? ?? + address['town'] as String? ?? + address['village'] as String?; + + _log.info('Web geocoded: $locality, $region, $country'); + return PlaceLookup(country: country, region: region, locality: locality); + } on SocketException catch (e) { + _log.warning('Web reverse geocoding unreachable: $e'); + return const PlaceLookup.unavailable(); + } catch (e) { + _log.warning('Web reverse geocoding failed: $e'); + return const PlaceLookup.empty(); + } + } + + /// One Nominatim GET. Returns the decoded object, or null for a non-200 + /// status. Lets socket errors propagate so callers can tell "offline" from + /// "nothing there". The client is closed in a finally so its sockets are + /// released even when the body or the JSON decode throws. + Future?> _fetchNominatimJson( + Uri url, + String languageCode, + ) async { + final client = HttpClient(); + client.userAgent = 'Submersion Dive Log App'; + try { + final request = await client.getUrl(url); + request.headers.set('Accept-Language', languageCode); + final response = await request.close(); + if (response.statusCode != 200) return null; + final body = await response.transform(utf8.decoder).join(); + return jsonDecode(body) as Map; + } finally { + client.close(); + } + } +``` + +- [ ] **Step 5: Update the callers** + +Each of these currently calls `reverseGeocode(lat, lon)`; add `languageCode: LocationService.defaultLanguageCode`: + +- `lib/features/dive_sites/presentation/pages/site_edit_page.dart:212-215` +- `lib/features/dive_sites/presentation/widgets/location_picker_map.dart:64-67` and `:99-102` +- `lib/features/maps/presentation/widgets/region_download_dialog.dart:71-74` +- `lib/features/dive_import/data/services/uddf_entity_importer.dart:1035-1038` and `:1108-1111` +- `test/integration/uddf_test_importer.dart:442` + +In `test/features/dive_sites/presentation/pages/site_edit_page_test.dart:31-58` change the fake's override to: + +```dart + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async => PlaceLookup(country: country, region: region); + + @override + Future getCurrentLocation({ + bool includeGeocoding = true, + Duration timeout = const Duration(seconds: 15), + String languageCode = LocationService.defaultLanguageCode, + }) async => LocationResult( + latitude: 12.3, + longitude: 45.6, + accuracy: 5, + country: country, + region: region, + ); +``` + +and add `import 'package:submersion/core/services/geocoding/place_lookup.dart';`. Run `grep -rn "reverseGeocode(\|getCurrentLocation(" test/ lib/ | grep -v location_service` and fix every remaining fake or caller the same way. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flutter test test/core/services/location_service_test.dart test/features/dive_sites/presentation/pages/site_edit_page_test.dart test/features/dive_sites/presentation/pages/site_edit_seed_location_test.dart` +Expected: all pass. + +Run: `flutter analyze` +Expected: `No issues found!` + +- [ ] **Step 7: Commit** + +```bash +dart format . && git add -A lib/core/services test/core/services lib/features test/features test/integration && git commit -m "refactor(location): return PlaceLookup and take the geocode language per call (#1187)" +``` + +--- + +### Task 2: Body of water from the Nominatim natural layer + +**Files:** +- Modify: `lib/core/services/location_service.dart` (`reverseGeocode`, new `buildNaturalFeatureUri`, `bodyOfWaterFromNaturalFeature`, `_lookupBodyOfWater`) +- Test: `test/core/services/location_service_test.dart` + +**Interfaces:** +- Consumes: `PlaceLookup`, `_fetchNominatimJson` from Task 1. +- Produces: `static Uri LocationService.buildNaturalFeatureUri(double latitude, double longitude, {required String languageCode})` +- Produces: `static String? LocationService.bodyOfWaterFromNaturalFeature(Map json)` (pure, public for tests) +- `reverseGeocode` now fills `PlaceLookup.bodyOfWater` from a second request. + +- [ ] **Step 1: Extend the fake server to answer per URI** + +In `test/core/services/location_service_test.dart`, change `_FakeNominatim` so a test can serve different bodies to the address and natural requests: + +```dart +class _FakeNominatim { + _FakeNominatim({this.statusCode = 200, this.body = '{}', this.bodyFor}); + + final int statusCode; + final String body; + + /// When set, wins over [body] for the given request. + final String? Function(Uri uri)? bodyFor; + + String bodyForUri(Uri uri) => bodyFor?.call(uri) ?? body; + ... +``` + +and in `_FakeHttpClientRequest.close()` use `_server.bodyForUri(uri)` instead of `_server.body`. + +Update the existing test `'sends the English pin in both the URI and the request headers'` (renamed in Task 1) and any test asserting `server.requestedUris, hasLength(1)`: a reverse geocode now makes two requests, so assert `hasLength(2)` and check `server.requestedUris.first` for the address URI. + +- [ ] **Step 2: Write the failing tests** + +Add a new group at the end of `main()`: + +```dart + group('body of water (issue #1187)', () { + Map address() => { + 'address': { + 'country': 'Switzerland', + 'state': 'Lucerne', + 'village': 'Weggis', + }, + }; + + String? natural(Uri uri, Map hit) => + uri.queryParameters['layer'] == 'natural' ? jsonEncode(hit) : null; + + test('the natural-layer URI asks for water features only', () { + final uri = LocationService.buildNaturalFeatureUri( + 47.027631, + 8.400640, + languageCode: 'de', + ); + expect(uri.host, 'nominatim.openstreetmap.org'); + expect(uri.path, '/reverse'); + expect(uri.queryParameters['layer'], 'natural'); + expect(uri.queryParameters['zoom'], '14'); + expect(uri.queryParameters['accept-language'], 'de'); + expect(uri.queryParameters['format'], 'json'); + }); + + test('a lake on the natural layer becomes the body of water', () async { + final server = _FakeNominatim( + body: jsonEncode(address()), + bodyFor: (uri) => natural(uri, { + 'class': 'water', + 'type': 'lake', + 'name': 'Lake Lucerne', + }), + ); + + final result = await server.run( + () => service.reverseGeocode(47.027631, 8.400640, languageCode: 'en'), + ); + + expect(result.locality, 'Weggis'); + expect(result.bodyOfWater, 'Lake Lucerne'); + expect(server.requestedUris, hasLength(2)); + expect(server.requestedUris.last.queryParameters['layer'], 'natural'); + }); + + test('a bay is accepted', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'bay', + 'name': 'Naama Bay', + }), + 'Naama Bay', + ); + }); + + test('a strait is accepted', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'strait', + 'name': 'Strait of Gibraltar', + }), + 'Strait of Gibraltar', + ); + }); + + test('a mountain range is not a body of water', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'mountain_range', + 'name': 'Urner Alps', + }), + isNull, + ); + }); + + test('a saddle is not a body of water', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'saddle', + 'name': 'coll Roig', + }), + isNull, + ); + }); + + test('an unable-to-geocode answer yields no body of water', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'error': 'Unable to geocode', + }), + isNull, + ); + }); + + test('a water hit with a blank name is ignored', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'water', + 'type': 'lake', + 'name': '', + }), + isNull, + ); + }); + + test('a failing natural-layer request keeps the address result', () async { + var calls = 0; + final server = _FakeNominatim( + body: jsonEncode(address()), + bodyFor: (uri) { + if (uri.queryParameters['layer'] != 'natural') return null; + calls++; + return 'this is not json'; + }, + ); + + final result = await server.run( + () => service.reverseGeocode(47.027631, 8.400640, languageCode: 'en'), + ); + + expect(calls, 1); + expect(result.country, 'Switzerland'); + expect(result.locality, 'Weggis'); + expect(result.bodyOfWater, isNull); + expect(result.networkFailed, isFalse); + }); + }); +``` + +- [ ] **Step 3: Run the test file to verify it fails** + +Run: `flutter test test/core/services/location_service_test.dart` +Expected: compile error, `buildNaturalFeatureUri` and `bodyOfWaterFromNaturalFeature` undefined. + +- [ ] **Step 4: Implement the natural-layer lookup** + +In `lib/core/services/location_service.dart`, after `buildReverseGeocodeUri` add: + +```dart + /// Nominatim reverse-geocode URI for the natural layer, which answers with + /// the lake, bay or strait a point lies in. zoom=14 keeps the answer to a + /// named feature rather than the whole region. Nominatim has no ocean + /// polygons, so open-sea points come back "Unable to geocode". + static Uri buildNaturalFeatureUri( + double latitude, + double longitude, { + required String languageCode, + }) => Uri.parse( + 'https://nominatim.openstreetmap.org/reverse?format=json' + '&lat=$latitude&lon=$longitude&zoom=14&layer=natural' + '&accept-language=$languageCode', + ); + + /// The name of a water feature from a natural-layer answer, or null when + /// the hit is not water. The natural layer also carries mountain ranges, + /// saddles and peaks; class `water` covers lakes, reservoirs and rivers, + /// and bays and straits arrive as class `natural`. + static String? bodyOfWaterFromNaturalFeature(Map json) { + final osmClass = json['class'] as String?; + final type = json['type'] as String?; + final name = (json['name'] as String?)?.trim(); + if (name == null || name.isEmpty) return null; + if (osmClass == 'water') return name; + if (osmClass == 'natural' && (type == 'bay' || type == 'strait')) { + return name; + } + return null; + } +``` + +In `reverseGeocode`, replace the two `return PlaceLookup(...)` / `return await _reverseGeocodeWeb(...)` statements so both paths add the water lookup: + +```dart + if (placemarks.isNotEmpty) { + final place = placemarks.first; + _log.info( + 'Native geocoded: ${place.locality}, ${place.administrativeArea}, ${place.country}', + ); + return _withBodyOfWater( + PlaceLookup( + country: place.country, + region: place.administrativeArea, + locality: place.locality, + ), + latitude, + longitude, + languageCode, + ); + } +``` + +and + +```dart + // Fallback to OpenStreetMap Nominatim API (works on all platforms) + final address = await _reverseGeocodeWeb(latitude, longitude, languageCode); + if (address.networkFailed) return address; + return _withBodyOfWater(address, latitude, longitude, languageCode); +``` + +Add the helpers after `_reverseGeocodeWeb`: + +```dart + Future _withBodyOfWater( + PlaceLookup address, + double latitude, + double longitude, + String languageCode, + ) async { + final water = await _lookupBodyOfWater(latitude, longitude, languageCode); + return water == null ? address : address.copyWith(bodyOfWater: water); + } + + /// Best-effort: any failure here leaves the address result untouched. + Future _lookupBodyOfWater( + double latitude, + double longitude, + String languageCode, + ) async { + try { + final json = await _fetchNominatimJson( + buildNaturalFeatureUri( + latitude, + longitude, + languageCode: languageCode, + ), + languageCode, + ); + if (json == null) return null; + final water = bodyOfWaterFromNaturalFeature(json); + _log.info('Natural layer: ${water ?? 'no water feature'}'); + return water; + } catch (e) { + _log.warning('Body of water lookup failed: $e'); + return null; + } + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `flutter test test/core/services/location_service_test.dart` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +dart format . && git add lib/core/services/location_service.dart test/core/services/location_service_test.dart && git commit -m "feat(location): read the body of water from the Nominatim natural layer (#1187)" +``` + +--- + +### Task 3: One Nominatim request per second + +**Files:** +- Create: `lib/core/services/geocoding/nominatim_throttle.dart` +- Modify: `lib/core/services/location_service.dart` (`_fetchNominatimJson`, `forwardGeocode`) +- Test: `test/core/services/geocoding/nominatim_throttle_test.dart`, `test/core/services/location_service_test.dart` (setUp) + +**Interfaces:** +- Produces: `class NominatimThrottle { NominatimThrottle({Duration minimumGap = const Duration(seconds: 1)}); Future wait(); }` +- Produces: `static NominatimThrottle LocationService.throttle` (replaceable, `@visibleForTesting`). + +- [ ] **Step 1: Write the failing throttle test** + +`test/core/services/geocoding/nominatim_throttle_test.dart`: + +```dart +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; + +void main() { + test('the first request goes through immediately', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + var released = false; + throttle.wait().then((_) => released = true); + async.flushMicrotasks(); + expect(released, isTrue); + }); + }); + + test('a second request waits until a second has passed', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + final releasedAt = []; + final start = clock.now(); + throttle.wait().then((_) => releasedAt.add(clock.now().difference(start))); + throttle.wait().then((_) => releasedAt.add(clock.now().difference(start))); + async.flushMicrotasks(); + expect(releasedAt, [Duration.zero]); + + async.elapse(const Duration(milliseconds: 999)); + expect(releasedAt, hasLength(1)); + + async.elapse(const Duration(milliseconds: 1)); + expect(releasedAt, [Duration.zero, const Duration(seconds: 1)]); + }); + }); + + test('requests spaced wider than the gap are not delayed', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + throttle.wait(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 3)); + + var released = false; + throttle.wait().then((_) => released = true); + async.flushMicrotasks(); + expect(released, isTrue); + }); + }); + + test('three queued requests are released one second apart', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + final start = clock.now(); + final releasedAt = []; + for (var i = 0; i < 3; i++) { + throttle.wait().then( + (_) => releasedAt.add(clock.now().difference(start)), + ); + } + async.elapse(const Duration(seconds: 2)); + expect(releasedAt, [ + Duration.zero, + const Duration(seconds: 1), + const Duration(seconds: 2), + ]); + }); + }); + + test('a zero gap never delays', () { + fakeAsync((async) { + final throttle = NominatimThrottle(minimumGap: Duration.zero); + var count = 0; + for (var i = 0; i < 5; i++) { + throttle.wait().then((_) => count++); + } + async.flushMicrotasks(); + expect(count, 5); + }); + }); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/core/services/geocoding/nominatim_throttle_test.dart` +Expected: compile error, `nominatim_throttle.dart` not found. + +- [ ] **Step 3: Implement the throttle** + +`lib/core/services/geocoding/nominatim_throttle.dart`: + +```dart +import 'package:clock/clock.dart'; + +/// Spaces Nominatim requests at least [minimumGap] apart, process-wide. +/// +/// OpenStreetMap's usage policy allows one request per second. A single +/// interactive lookup makes two requests (address layer, then natural +/// layer) and the bulk backfill makes hundreds, so the spacing lives in one +/// place instead of at every call site. Waiters are released in call order. +/// +/// Uses `clock.now()` rather than `Stopwatch` so fake_async tests can drive +/// it; a `Stopwatch` is invisible to the synthetic clock. +class NominatimThrottle { + NominatimThrottle({this.minimumGap = const Duration(seconds: 1)}); + + final Duration minimumGap; + + DateTime? _lastRelease; + Future _queue = Future.value(); + + /// Completes when the caller may send its request. + Future wait() { + final turn = _queue.then((_) => _holdUntilGapElapsed()); + _queue = turn; + return turn; + } + + Future _holdUntilGapElapsed() async { + final last = _lastRelease; + if (last != null) { + final sinceLast = clock.now().difference(last); + if (sinceLast < minimumGap) { + await Future.delayed(minimumGap - sinceLast); + } + } + _lastRelease = clock.now(); + } +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `flutter test test/core/services/geocoding/nominatim_throttle_test.dart` +Expected: all pass. + +- [ ] **Step 5: Wire the throttle into the service and its tests** + +In `lib/core/services/location_service.dart` add `import 'package:submersion/core/services/geocoding/nominatim_throttle.dart';` and, inside `LocationService` after `debugForceNativeGeocoder`: + +```dart + /// Process-wide spacing for every Nominatim request. Tests replace it with + /// a zero-gap instance so lookups do not wait a real second each. + @visibleForTesting + static NominatimThrottle throttle = NominatimThrottle(); +``` + +At the top of `_fetchNominatimJson`, before `final client = HttpClient();`, add `await throttle.wait();`. In `forwardGeocode`, add `await throttle.wait();` immediately before `final client = HttpClient();`. + +In `test/core/services/location_service_test.dart` add a file-level `setUp` at the top of `main()`: + +```dart + setUp(() { + LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + }); +``` + +with `import 'package:submersion/core/services/geocoding/nominatim_throttle.dart';`. Do the same in every test that overrides `HttpOverrides` to reach `LocationService` (`grep -rln "LocationService.instance" test/`). + +Add one integration-style test to the `body of water` group proving the two requests of one lookup are spaced: + +```dart + test('the address and natural requests are a second apart', () { + fakeAsync((async) { + LocationService.throttle = NominatimThrottle(); + final start = clock.now(); + final seenAt = []; + final server = _FakeNominatim( + body: jsonEncode(address()), + bodyFor: (uri) { + seenAt.add(clock.now().difference(start)); + return uri.queryParameters['layer'] == 'natural' + ? jsonEncode({'class': 'water', 'type': 'lake', 'name': 'L'}) + : null; + }, + ); + PlaceLookup? result; + server + .run(() => service.reverseGeocode(47.0, 8.4, languageCode: 'en')) + .then((r) => result = r); + async.elapse(const Duration(seconds: 1)); + expect(seenAt, [Duration.zero, const Duration(seconds: 1)]); + expect(result?.bodyOfWater, 'L'); + }); + }); +``` + +(imports: `package:clock/clock.dart`, `package:fake_async/fake_async.dart`, `package:submersion/core/services/geocoding/place_lookup.dart`.) + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flutter test test/core/services` +Expected: all pass. Then `flutter analyze`: `No issues found!` + +- [ ] **Step 7: Commit** + +```bash +dart format . && git add lib/core/services test/core/services && git commit -m "feat(location): space Nominatim requests one second apart (#1187)" +``` + +--- + +### Task 4: Schema v162, `diver_settings.place_name_language` + +**Files:** +- Create: `lib/core/constants/place_name_language.dart` +- Modify: `lib/core/database/database.dart` (`DiverSettings` table near line 1663, `currentSchemaVersion` line 3168, `migrationVersions` after line 3452, helper near `_assertO2CellMvDefaultColumn` line 4908, ladder step after line 8537) +- Test: `test/core/database/migration_v162_place_name_language_test.dart`, `test/core/constants/place_name_language_test.dart` + +**Interfaces:** +- Produces: `abstract final class PlaceNameLanguage { static const String defaultCode = 'en'; static const List supportedCodes; static String normalize(String? code); }` +- Produces: Drift column `DiverSettings.placeNameLanguage` (`place_name_language TEXT NOT NULL DEFAULT 'en'`), generated `DiverSetting.placeNameLanguage` and `DiverSettingsCompanion.placeNameLanguage`. + +- [ ] **Step 1: Write the failing tests** + +`test/core/constants/place_name_language_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/place_name_language.dart'; + +void main() { + test('English is the default', () { + expect(PlaceNameLanguage.defaultCode, 'en'); + }); + + test('every app language except system is supported', () { + expect(PlaceNameLanguage.supportedCodes, [ + 'en', + 'es', + 'fr', + 'de', + 'it', + 'nl', + 'pt', + 'hu', + 'ar', + 'he', + 'zh', + ]); + }); + + test('normalize keeps a supported code', () { + expect(PlaceNameLanguage.normalize('de'), 'de'); + }); + + test('normalize falls back to English for unknown, null or blank', () { + expect(PlaceNameLanguage.normalize('xx'), 'en'); + expect(PlaceNameLanguage.normalize(null), 'en'); + expect(PlaceNameLanguage.normalize(''), 'en'); + expect(PlaceNameLanguage.normalize('system'), 'en'); + }); +} +``` + +`test/core/database/migration_v162_place_name_language_test.dart`: + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v162 shape: a diver_settings table without the place name +/// language column, stamped at v161 so the 161->162 upgrade 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("v162 adds place_name_language defaulting to 'en'", () 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('place_name_language')); + + final row = await db + .customSelect('SELECT place_name_language FROM diver_settings') + .getSingle(); + expect(row.read('place_name_language'), 'en'); + }); + + test('fresh databases get the place_name_language 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('place_name_language')); + }); + + 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('v162 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(162)); + expect(AppDatabase.migrationVersions, contains(162)); + }); +} +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `flutter test test/core/constants/place_name_language_test.dart test/core/database/migration_v162_place_name_language_test.dart` +Expected: the constants test fails to compile (file missing); the migration test fails on `contains('place_name_language')` and `contains(162)`. + +- [ ] **Step 3: Create the constants file** + +`lib/core/constants/place_name_language.dart`: + +```dart +/// The language reverse-geocoded place names are stored in. +/// +/// A synced per-diver setting (issue #1187). Stored as the ISO 639-1 code, +/// never as a display name. English is the default because every row +/// written before the setting existed was geocoded in English (issue #214), +/// and mixing languages within one logbook splits a country across two +/// statistics buckets. There is deliberately no "follow app language" +/// value: the app language can be `system`, which resolves per device. +abstract final class PlaceNameLanguage { + static const String defaultCode = 'en'; + + /// The app's own languages, in the order the language picker lists them. + static const List supportedCodes = [ + 'en', + 'es', + 'fr', + 'de', + 'it', + 'nl', + 'pt', + 'hu', + 'ar', + 'he', + 'zh', + ]; + + /// A supported code, or [defaultCode] for anything else. A synced peer on a + /// newer build could send a code this build does not know. + static String normalize(String? code) => + code != null && supportedCodes.contains(code) ? code : defaultCode; +} +``` + +- [ ] **Step 4: Add the column, helper, and ladder step** + +In `lib/core/database/database.dart`: + +After the `locale` column in `class DiverSettings` (line 1663) add: + +```dart + // Language for reverse-geocoded place names, ISO 639-1 (issue #1187, v162) + TextColumn get placeNameLanguage => + text().withDefault(const Constant('en'))(); +``` + +Change `static const int currentSchemaVersion = 161;` to `162`. + +Append to `migrationVersions` after the `161,` entry: + +```dart + // v162: diver_settings.place_name_language, the synced language used for + // reverse-geocoded country/region/town/body of water (issue #1187). + 162, +``` + +After `_assertO2CellMvDefaultColumn` add: + +```dart + /// v162: place_name_language on diver_settings (issue #1187). Defaults to + /// 'en', the language every pre-v162 row was geocoded in (issue #214). + Future _assertPlaceNameLanguageColumn() 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('place_name_language')) { + await customStatement( + "ALTER TABLE diver_settings ADD COLUMN place_name_language TEXT " + "NOT NULL DEFAULT 'en'", + ); + } + } +``` + +After the `if (from < 161) await reportProgress();` line add: + +```dart + // v162: place_name_language on diver_settings (issue #1187). + if (from < 162) { + await _assertPlaceNameLanguageColumn(); + } + if (from < 162) await reportProgress(); +``` + +- [ ] **Step 5: Regenerate Drift code and run the tests** + +Run: `dart run build_runner build --delete-conflicting-outputs 2>&1 | tail -1` +Expected: `wrote N outputs` with no errors. + +Run: `flutter test test/core/constants/place_name_language_test.dart test/core/database/migration_v162_place_name_language_test.dart test/core/database` +Expected: all pass (the other ladder tests assert `greaterThanOrEqualTo`, so they stay green). + +- [ ] **Step 6: Commit** + +```bash +dart format . && git add lib/core/constants/place_name_language.dart lib/core/database/database.dart test/core/constants/place_name_language_test.dart test/core/database/migration_v162_place_name_language_test.dart && git commit -m "feat(db): v162 diver_settings.place_name_language (#1187)" +``` + +--- + +### Task 5: `AppSettings.placeNameLanguage`, repository, sync default, provider + +**Files:** +- Modify: `lib/features/settings/presentation/providers/settings_providers.dart` (field near line 181 `locale`, constructor default near 488, `copyWith` parameter near 648 and assignment near 777, setter near `setLocale` line 1353, selector near `localeProvider` line 2001) +- Modify: `lib/features/settings/data/repositories/diver_settings_repository.dart` (insert companion near line 101, update companion near 261, row mapping near 465) +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (`_applyDiverSettingDefaults`, after the `'defaultShowO2CellMv': false,` entry near line 5665) +- Test: `test/features/settings/data/repositories/diver_settings_place_name_language_test.dart`, `test/core/services/sync/sync_diver_settings_fallback_test.dart` + +**Interfaces:** +- Consumes: `PlaceNameLanguage` from Task 4. +- Produces: `AppSettings.placeNameLanguage` (String, default `'en'`), `AppSettings.copyWith({String? placeNameLanguage})`, `SettingsNotifier.setPlaceNameLanguage(String code)`, `final placeNameLanguageProvider = Provider`. + +- [ ] **Step 1: Write the failing repository test** + +`test/features/settings/data/repositories/diver_settings_place_name_language_test.dart`: + +```dart +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.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() { + 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: 'diver-1', + name: 'Test Diver', + createdAt: now, + updatedAt: now, + ), + ); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('defaults to English', () { + expect(const AppSettings().placeNameLanguage, 'en'); + }); + + test('round-trips the place name language', () async { + await repository.updateSettingsForDiver( + 'diver-1', + const AppSettings(placeNameLanguage: 'de'), + ); + + final loaded = await repository.getSettingsForDiver('diver-1'); + expect(loaded.placeNameLanguage, 'de'); + }); + + test('an unknown stored code loads as English', () async { + await repository.updateSettingsForDiver('diver-1', const AppSettings()); + await (db.update(db.diverSettings)..where((t) => t.diverId.equals('diver-1'))) + .write(const DiverSettingsCompanion(placeNameLanguage: Value('xx'))); + + final loaded = await repository.getSettingsForDiver('diver-1'); + expect(loaded.placeNameLanguage, 'en'); + }); +} +``` + +Before writing this, confirm the repository's read and write method names and the `Divers` insert companion's required fields with `grep -n "Future get\|Future updateSettingsForDiver" lib/features/settings/data/repositories/diver_settings_repository.dart` and `grep -n "class DiversCompanion" -A 40 lib/core/database/database.g.dart | grep required`; adjust the test to the real names if they differ. + +- [ ] **Step 2: Extend the sync fallback test** + +Add to `test/core/services/sync/sync_diver_settings_fallback_test.dart`, following the shape of the existing tests: + +```dart + test( + 'applies a pre-v162 diver_settings payload missing placeNameLanguage', + () async { + await db.customStatement('PRAGMA foreign_keys = OFF'); + + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diverSettings) + .insert( + DiverSettingsCompanion.insert( + id: 'ds-162', + diverId: 'diver-1', + createdAt: now, + updatedAt: now, + ), + ); + final exported = await serializer.fetchRecord('diverSettings', 'ds-162'); + final legacy = Map.from(exported!) + ..remove('placeNameLanguage'); + await (db.delete( + db.diverSettings, + )..where((t) => t.id.equals('ds-162'))).go(); + + await serializer.upsertRecord('diverSettings', legacy); + + final row = await (db.select( + db.diverSettings, + )..where((t) => t.id.equals('ds-162'))).getSingle(); + expect(row.placeNameLanguage, 'en'); + }, + ); +``` + +- [ ] **Step 3: Run both to verify they fail** + +Run: `flutter test test/features/settings/data/repositories/diver_settings_place_name_language_test.dart test/core/services/sync/sync_diver_settings_fallback_test.dart` +Expected: the repository test fails to compile (`placeNameLanguage` is not a named parameter of `AppSettings`); the sync test throws in `DiverSetting.fromJson` on the missing key. + +- [ ] **Step 4: Implement** + +`lib/features/settings/presentation/providers/settings_providers.dart`: + +- Add `import 'package:submersion/core/constants/place_name_language.dart';`. +- After `final String locale;` (line 181) add: + +```dart + /// ISO 639-1 code for reverse-geocoded place names (issue #1187). Synced + /// with the diver so every device stores the same spelling. + final String placeNameLanguage; +``` + +- In the constructor after `this.locale = 'system',` add `this.placeNameLanguage = PlaceNameLanguage.defaultCode,`. +- In `copyWith` parameters after `String? locale,` add `String? placeNameLanguage,`; in the assignments after `locale: locale ?? this.locale,` add `placeNameLanguage: placeNameLanguage ?? this.placeNameLanguage,`. +- After `setLocale` add: + +```dart + Future setPlaceNameLanguage(String code) async { + state = state.copyWith( + placeNameLanguage: PlaceNameLanguage.normalize(code), + ); + await _saveSettings(); + } +``` + +- After `localeProvider` add: + +```dart +/// The language new reverse-geocode results are stored in (issue #1187). +final placeNameLanguageProvider = Provider((ref) { + return ref.watch(settingsProvider.select((s) => s.placeNameLanguage)); +}); +``` + +`lib/features/settings/data/repositories/diver_settings_repository.dart`: + +- Add `import 'package:submersion/core/constants/place_name_language.dart';`. +- Insert companion: after `locale: Value(s.locale),` add `placeNameLanguage: Value(s.placeNameLanguage),`. +- Update companion: after `locale: Value(settings.locale),` add `placeNameLanguage: Value(settings.placeNameLanguage),`. +- Row mapping: after `locale: row.locale,` add `placeNameLanguage: PlaceNameLanguage.normalize(row.placeNameLanguage),`. + +`lib/core/services/sync/sync_data_serializer.dart`, in `_applyDiverSettingDefaults` after the `'defaultShowO2CellMv': false,` entry: + +```dart + // v162: seed it so payloads predating the column hydrate instead of + // throwing in DiverSetting.fromJson (issue #1187). + 'placeNameLanguage': 'en', +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `flutter test test/features/settings test/core/services/sync/sync_diver_settings_fallback_test.dart` +Expected: all pass. `flutter analyze`: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +dart format . && git add lib/features/settings lib/core/services/sync/sync_data_serializer.dart test/features/settings test/core/services/sync/sync_diver_settings_fallback_test.dart && git commit -m "feat(settings): synced place name language preference (#1187)" +``` + +--- + +### Task 6: Settings row and picker, with localisation + +**Files:** +- Create: `lib/features/settings/presentation/widgets/place_name_language_picker.dart` +- Modify: `lib/features/settings/presentation/pages/language_settings_page.dart` (rename `_LocaleOption` to `LocaleOption`, lines 12-43 and its class declaration further down) +- Modify: `lib/features/settings/presentation/pages/settings_page.dart` (after the coordinate-format `_buildUnitTile`, lines 548-557; import) +- Modify: all 11 ARB files +- Test: `test/features/settings/presentation/widgets/place_name_language_picker_test.dart` + +**Interfaces:** +- Consumes: `placeNameLanguageProvider`, `SettingsNotifier.setPlaceNameLanguage`, `PlaceNameLanguage.supportedCodes`. +- Produces: `void showPlaceNameLanguagePicker(BuildContext context, WidgetRef ref, AppSettings settings)`, `class PlaceNameLanguageList`, `String placeNameLanguageLabel(String code)` (returns the native name, e.g. `Deutsch`), `class LocaleOption` (public, in `language_settings_page.dart`). +- Produces l10n keys: `settings_placeNameLanguage_title`, `settings_placeNameLanguage_subtitle`. + +- [ ] **Step 1: Add the strings to every ARB** + +`lib/l10n/arb/app_en.arb`, next to `settings_coordinateFormat_subtitle`: + +```json + "settings_placeNameLanguage_title": "Place name language", + "settings_placeNameLanguage_subtitle": "Used when country, region, town and body of water are looked up from coordinates. Existing sites are not changed.", +``` + +The other ten, each next to their own `settings_coordinateFormat_subtitle`: + +- `app_de.arb`: `"Sprache der Ortsnamen"` / `"Wird verwendet, wenn Land, Region, Ort und Gewässer aus Koordinaten ermittelt werden. Bestehende Tauchplätze werden nicht geändert."` +- `app_es.arb`: `"Idioma de los nombres de lugar"` / `"Se usa al obtener país, región, localidad y masa de agua a partir de las coordenadas. Los puntos de buceo existentes no cambian."` +- `app_fr.arb`: `"Langue des noms de lieux"` / `"Utilisée lorsque le pays, la région, la ville et le plan d'eau sont déduits des coordonnées. Les sites existants ne sont pas modifiés."` +- `app_it.arb`: `"Lingua dei nomi dei luoghi"` / `"Usata quando paese, regione, città e specchio d'acqua vengono ricavati dalle coordinate. I siti esistenti non vengono modificati."` +- `app_nl.arb`: `"Taal van plaatsnamen"` / `"Gebruikt wanneer land, regio, plaats en water uit coördinaten worden opgezocht. Bestaande duikstekken worden niet gewijzigd."` +- `app_pt.arb`: `"Idioma dos nomes de lugares"` / `"Usado quando país, região, cidade e corpo de água são obtidos a partir das coordenadas. Os locais existentes não são alterados."` +- `app_hu.arb`: `"Helynevek nyelve"` / `"Akkor használjuk, amikor az ország, régió, település és víztest a koordinátákból kerül lekérdezésre. A meglévő merülőhelyek nem változnak."` +- `app_ar.arb`: `"لغة أسماء الأماكن"` / `"تُستخدم عند البحث عن البلد والمنطقة والبلدة والمسطح المائي من الإحداثيات. لا يتم تغيير المواقع الحالية."` +- `app_he.arb`: `"שפת שמות המקומות"` / `"בשימוש כאשר מדינה, אזור, עיר וגוף מים נשלפים מהקואורדינטות. אתרים קיימים אינם משתנים."` +- `app_zh.arb`: `"地名语言"` / `"根据坐标查找国家、地区、城镇和水域时使用。现有潜点不会更改。"` + +Run: `flutter gen-l10n` +Expected: no errors; `grep -c placeNameLanguage lib/l10n/arb/app_localizations.dart` prints a number greater than 0. + +- [ ] **Step 2: Write the failing picker test** + +`test/features/settings/presentation/widgets/place_name_language_picker_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/place_name_language_picker.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Stands in for SettingsNotifier so the picker's saves can be inspected +/// without a database. Only setPlaceNameLanguage is exercised here. +class _RecordingSettingsNotifier extends StateNotifier + implements SettingsNotifier { + final List saved; + + _RecordingSettingsNotifier(super.initial, this.saved); + + @override + Future setPlaceNameLanguage(String code) async { + state = state.copyWith(placeNameLanguage: code); + saved.add(code); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late List saved; + late ProviderContainer container; + + setUp(() { + saved = []; + container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith( + (ref) => _RecordingSettingsNotifier(const AppSettings(), saved), + ), + ], + ); + addTearDown(container.dispose); + }); + + Widget host(Widget child) => UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), + ); + + Future openPicker(WidgetTester tester) async { + await tester.pumpWidget( + host( + Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => showPlaceNameLanguagePicker( + context, + ref, + container.read(settingsProvider), + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('offers every supported language by its native name', ( + tester, + ) async { + await openPicker(tester); + for (final name in ['English', 'Deutsch', 'Espanol', 'Magyar', '简体中文']) { + expect(find.text(name), findsOneWidget, reason: 'missing $name'); + } + expect(find.text('System Default'), findsNothing); + }); + + testWidgets('marks the current language', (tester) async { + await openPicker(tester); + final tile = find.ancestor( + of: find.text('English'), + matching: find.byType(ListTile), + ); + expect( + find.descendant(of: tile, matching: find.byIcon(Icons.check)), + findsOneWidget, + ); + }); + + testWidgets('selecting a language saves it and closes', (tester) async { + await openPicker(tester); + await tester.tap(find.text('Deutsch')); + await tester.pumpAndSettle(); + + expect(saved, ['de']); + expect(find.byType(PlaceNameLanguageList), findsNothing); + }); + + test('the label is the native name, falling back to the code', () { + expect(placeNameLanguageLabel('de'), 'Deutsch'); + expect(placeNameLanguageLabel('en'), 'English'); + expect(placeNameLanguageLabel('xx'), 'xx'); + }); +} +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `flutter test test/features/settings/presentation/widgets/place_name_language_picker_test.dart` +Expected: compile error, picker file missing. + +- [ ] **Step 4: Make `LocaleOption` public and write the picker** + +In `lib/features/settings/presentation/pages/language_settings_page.dart` rename `_LocaleOption` to `LocaleOption` everywhere in the file (`grep -n "_LocaleOption" lib/ test/` must then return nothing). + +`lib/features/settings/presentation/widgets/place_name_language_picker.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'package:submersion/core/constants/place_name_language.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/pages/language_settings_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// The place name language picker (issue #1187), split out of +/// `settings_page.dart` so it can be pumped directly in tests. +/// +/// The options are the app's own languages minus "System Default": the value +/// must resolve to the same code on every one of the diver's devices, which +/// a device-dependent choice cannot promise. + +/// Opens the place name language picker. +void showPlaceNameLanguagePicker( + BuildContext context, + WidgetRef ref, + AppSettings settings, +) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text( + AppLocalizations.of(context).settings_placeNameLanguage_title, + ), + content: PlaceNameLanguageList( + selected: settings.placeNameLanguage, + onSelected: (code) { + Navigator.of(dialogContext).pop(); + ref.read(settingsProvider.notifier).setPlaceNameLanguage(code); + }, + ), + ), + ); +} + +/// The supported languages, each by its native name. +class PlaceNameLanguageList extends StatelessWidget { + const PlaceNameLanguageList({ + super.key, + required this.selected, + required this.onSelected, + }); + + final String selected; + final void Function(String code) onSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 360, + child: ListView( + shrinkWrap: true, + children: [ + for (final code in PlaceNameLanguage.supportedCodes) + ListTile( + title: Text(placeNameLanguageLabel(code)), + trailing: code == selected + ? Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () => onSelected(code), + ), + ], + ), + ); + } +} + +/// The native name of a language code, from the app language list, so there +/// is no second hand-maintained list of names. +String placeNameLanguageLabel(String code) { + for (final option in LanguageSettingsPage.supportedLocales) { + if (option.code == code) return option.nativeName; + } + return code; +} +``` + +In `lib/features/settings/presentation/pages/settings_page.dart` add `import 'package:submersion/features/settings/presentation/widgets/place_name_language_picker.dart';` and, directly after the coordinate-format `_buildUnitTile(...)` call (line 557), add: + +```dart + const Divider(height: 1), + ListTile( + title: Text(context.l10n.settings_placeNameLanguage_title), + subtitle: Text( + context.l10n.settings_placeNameLanguage_subtitle, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + placeNameLanguageLabel(settings.placeNameLanguage), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => + showPlaceNameLanguagePicker(context, ref, settings), + ), +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `flutter test test/features/settings` +Expected: all pass. `flutter analyze`: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +dart format . && git add lib/features/settings lib/l10n test/features/settings && git commit -m "feat(settings): place name language row and picker (#1187)" +``` + +--- + +### Task 7: Every geocode caller uses the diver's place name language + +**Files:** +- Modify: `lib/features/dive_sites/presentation/pages/site_edit_page.dart` (`_geocodeSeed` line 210, `_useMyLocation` line 1344) +- Modify: `lib/features/dive_sites/presentation/widgets/location_picker_map.dart` (`_updateLocationPreview` line 64, `_confirmSelection` line 99) +- Modify: `lib/features/maps/presentation/widgets/region_download_dialog.dart` (line 71; check whether the widget is a `ConsumerStatefulWidget`, and if not convert it, keeping everything else unchanged) +- Modify: `lib/features/dive_import/data/services/uddf_entity_importer.dart` (constructor line 230, lookups at 1035 and 1108) +- Modify: `lib/features/import_wizard/data/adapters/universal_adapter.dart` (line 525) +- Test: `test/features/dive_sites/presentation/pages/site_edit_language_test.dart`, `test/features/dive_import/data/services/uddf_entity_importer_language_test.dart` + +**Interfaces:** +- Consumes: `placeNameLanguageProvider` (Task 5), `reverseGeocode(..., languageCode:)` (Task 1). +- Produces: `UddfEntityImporter({..., String placeNameLanguage = LocationService.defaultLanguageCode})`. + +- [ ] **Step 1: Write the failing site-form test** + +`test/features/dive_sites/presentation/pages/site_edit_language_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/pages/site_edit_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Records the language every geocode was asked for. +class _RecordingLocationService implements LocationService { + final List languages = []; + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async { + languages.add(languageCode); + return const PlaceLookup(country: 'Schweiz', region: 'Luzern'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// A settings notifier that starts with German place names. +class _GermanSettings extends StateNotifier + implements SettingsNotifier { + _GermanSettings() : super(const AppSettings(placeNameLanguage: 'de')); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + testWidgets('seeding a new site geocodes in the place name language', ( + tester, + ) async { + final location = _RecordingLocationService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + allDiversProvider.overrideWith((_) async => const []), + validatedCurrentDiverIdProvider.overrideWith((_) async => null), + settingsProvider.overrideWith((_) => _GermanSettings()), + locationServiceProvider.overrideWithValue(location), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SiteEditPage( + initialLocation: const GeoPoint(47.027631, 8.400640), + embedded: true, + onSaved: (_) {}, + onCancel: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(location.languages, ['de']); + expect(find.text('Schweiz'), findsOneWidget); + }); +} +``` + +Check `test/features/dive_sites/presentation/pages/site_edit_seed_location_test.dart` for the exact set of overrides that page needs (for example `shareByDefaultProvider`) and mirror them; the list above is the minimum from `site_edit_page_test.dart`. + +- [ ] **Step 2: Write the failing importer test** + +`test/features/dive_import/data/services/uddf_entity_importer_language_test.dart`: read the top of `test/features/dive_import/data/services/uddf_entity_importer_test.dart` for how an importer is constructed with mock repositories and how a site with coordinates but no country is fed in (`grep -n "reverseGeocode\|latitude" test/features/dive_import/data/services/uddf_entity_importer_test.dart`). Write one test that: + +1. Installs `HttpOverrides` with a fake client capturing the `accept-language` query parameter (copy `_FakeNominatim` and its three helper classes from `test/core/services/location_service_test.dart` into this file, or extract them to `test/helpers/fake_nominatim.dart` first and import from both places). +2. Constructs `UddfEntityImporter(placeNameLanguage: 'fr')` and imports one site with latitude/longitude and no country. +3. Asserts the captured URI has `accept-language=fr`. + +Also set `LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero);` in `setUp`. + +- [ ] **Step 3: Run both to verify they fail** + +Run: `flutter test test/features/dive_sites/presentation/pages/site_edit_language_test.dart test/features/dive_import/data/services/uddf_entity_importer_language_test.dart` +Expected: the site test sees `['en']`; the importer test fails to compile (`placeNameLanguage` is not a parameter). + +- [ ] **Step 4: Wire the provider** + +`site_edit_page.dart`: +- `_geocodeSeed`: replace `languageCode: LocationService.defaultLanguageCode` with `languageCode: ref.read(placeNameLanguageProvider)`. +- `_useMyLocation`: `locationService.getCurrentLocation(includeGeocoding: true, languageCode: ref.read(placeNameLanguageProvider))`. + +`location_picker_map.dart`: in both `_updateLocationPreview` and `_confirmSelection`, replace the constant with `ref.read(placeNameLanguageProvider)` (the widget is already a `ConsumerStatefulWidget`; add the settings import if missing). + +`region_download_dialog.dart`: replace the constant with `ref.read(placeNameLanguageProvider)`; if the widget is not a `ConsumerStatefulWidget`, change `StatefulWidget` to `ConsumerStatefulWidget` and `State<...>` to `ConsumerState<...>` and add `import 'package:submersion/core/providers/provider.dart';` plus the settings providers import. + +`uddf_entity_importer.dart`: + +```dart + final TankPresetEntity? _defaultTankPreset; + final int _defaultStartPressure; + final bool _applyDefaultTankToImports; + final String _placeNameLanguage; + + UddfEntityImporter({ + TankPresetEntity? defaultTankPreset, + int defaultStartPressure = 200, + bool applyDefaultTankToImports = false, + String placeNameLanguage = LocationService.defaultLanguageCode, + }) : _defaultTankPreset = defaultTankPreset, + _defaultStartPressure = defaultStartPressure, + _applyDefaultTankToImports = applyDefaultTankToImports, + _placeNameLanguage = placeNameLanguage; +``` + +and both lookups pass `languageCode: _placeNameLanguage`. + +`universal_adapter.dart` line 525: + +```dart + final importer = UddfEntityImporter( + defaultTankPreset: defaultTankPreset, + defaultStartPressure: settings.defaultStartPressure, + applyDefaultTankToImports: settings.applyDefaultTankToImports, + placeNameLanguage: settings.placeNameLanguage, + ); +``` + +Finally `grep -rn "LocationService.defaultLanguageCode" lib/` must list only `location_service.dart` and the `UddfEntityImporter` default parameter. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_sites test/features/dive_import test/features/maps test/features/import_wizard` +Expected: all pass. `flutter analyze`: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +dart format . && git add -A lib test && git commit -m "feat(location): geocode in the diver's place name language (#1187)" +``` + +--- + +### Task 8: The "only empty fields" rule and the repository patch + +**Files:** +- Create: `lib/features/dive_sites/domain/services/site_location_merge.dart` +- Modify: `lib/features/dive_sites/data/repositories/site_repository_impl.dart` (add `fillMissingLocationDetails` next to `applyImportedMetadata`, line 238) +- Test: `test/features/dive_sites/domain/services/site_location_merge_test.dart`, `test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart` + +**Interfaces:** +- Consumes: `PlaceLookup` (Task 1). +- Produces: `class SiteLocationDetails { const SiteLocationDetails({String? country, String? region, String? city, String? bodyOfWater}); factory SiteLocationDetails.ofSite(DiveSite site); bool get isEmpty; }` +- Produces: `SiteLocationDetails? mergeMissingLocationDetails({required SiteLocationDetails current, required PlaceLookup found})`: the values to write (null where nothing changes), or null when nothing changes. +- Produces: `Future SiteRepository.fillMissingLocationDetails(String siteId, PlaceLookup found)`. + +- [ ] **Step 1: Write the failing merge tests** + +`test/features/dive_sites/domain/services/site_location_merge_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_merge.dart'; + +void main() { + const found = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', + ); + + test('fills every empty field', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(), + found: found, + ); + expect(merged, isNotNull); + expect(merged!.country, 'Switzerland'); + expect(merged.region, 'Lucerne'); + expect(merged.city, 'Weggis'); + expect(merged.bodyOfWater, 'Lake Lucerne'); + }); + + test('leaves filled fields alone and returns only the empty ones', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails( + country: 'Schweiz', + region: 'Luzern', + ), + found: found, + ); + expect(merged!.country, isNull); + expect(merged.region, isNull); + expect(merged.city, 'Weggis'); + expect(merged.bodyOfWater, 'Lake Lucerne'); + }); + + test('treats whitespace-only as empty', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(city: ' '), + found: const PlaceLookup(locality: 'Weggis'), + ); + expect(merged!.city, 'Weggis'); + }); + + test('ignores blank found values', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(), + found: const PlaceLookup(country: '', locality: ' '), + ); + expect(merged, isNull); + }); + + test('returns null when every field is already filled', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails( + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + ), + found: found, + ); + expect(merged, isNull); + }); + + test('returns null when the lookup found nothing', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(), + found: const PlaceLookup.empty(), + ); + expect(merged, isNull); + }); + + test('ofSite reads the four location columns', () { + const site = DiveSite( + id: 's', + name: 'n', + country: 'Switzerland', + city: 'Weggis', + ); + final details = SiteLocationDetails.ofSite(site); + expect(details.country, 'Switzerland'); + expect(details.region, isNull); + expect(details.city, 'Weggis'); + expect(details.bodyOfWater, isNull); + }); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/features/dive_sites/domain/services/site_location_merge_test.dart` +Expected: compile error, file missing. + +- [ ] **Step 3: Implement the merge** + +`lib/features/dive_sites/domain/services/site_location_merge.dart`: + +```dart +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +/// The four site columns a reverse geocode can fill. +class SiteLocationDetails { + const SiteLocationDetails({ + this.country, + this.region, + this.city, + this.bodyOfWater, + }); + + factory SiteLocationDetails.ofSite(DiveSite site) => SiteLocationDetails( + country: site.country, + region: site.region, + city: site.city, + bodyOfWater: site.bodyOfWater, + ); + + final String? country; + final String? region; + final String? city; + final String? bodyOfWater; + + bool get isEmpty => + country == null && region == null && city == null && bodyOfWater == null; +} + +bool _isBlank(String? value) => value == null || value.trim().isEmpty; + +/// The single home of the "only fill empty fields" rule (issue #1187). +/// +/// Returns the values to write, with null for every field that must not +/// change, or null when nothing should change. A field is filled only when +/// [current] is blank and [found] has a non-blank value for it; manual edits +/// and deliberate clears are never overwritten here. The lookup's locality +/// maps to the site's city column. +SiteLocationDetails? mergeMissingLocationDetails({ + required SiteLocationDetails current, + required PlaceLookup found, +}) { + String? fill(String? existing, String? candidate) => + _isBlank(existing) && !_isBlank(candidate) ? candidate!.trim() : null; + + final merged = SiteLocationDetails( + country: fill(current.country, found.country), + region: fill(current.region, found.region), + city: fill(current.city, found.locality), + bodyOfWater: fill(current.bodyOfWater, found.bodyOfWater), + ); + return merged.isEmpty ? null : merged; +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +Run: `flutter test test/features/dive_sites/domain/services/site_location_merge_test.dart` +Expected: all pass. + +- [ ] **Step 5: Write the failing repository test** + +`test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart' show AppDatabase; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late SiteRepository sites; + + setUp(() async { + db = await setUpTestDatabase(); + sites = SiteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + const found = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', + ); + + test('fills only the empty columns and reports a change', () async { + await sites.createSite( + const DiveSite( + id: 's1', + name: 'Hertenstein', + country: 'Schweiz', + rating: 4, + location: GeoPoint(47.027631, 8.400640), + ), + ); + + final changed = await sites.fillMissingLocationDetails('s1', found); + + expect(changed, isTrue); + final stored = await sites.getSiteById('s1'); + expect(stored!.country, 'Schweiz', reason: 'filled values are kept'); + expect(stored.region, 'Lucerne'); + expect(stored.city, 'Weggis'); + expect(stored.bodyOfWater, 'Lake Lucerne'); + expect(stored.rating, 4, reason: 'unrelated columns untouched'); + }); + + test('marks the site pending for sync when it changed', () async { + await sites.createSite(const DiveSite(id: 's2', name: 'n')); + await (db.delete(db.syncRecords)..where((t) => t.recordId.equals('s2'))).go(); + + await sites.fillMissingLocationDetails('s2', found); + + final pending = await (db.select( + db.syncRecords, + )..where((t) => t.recordId.equals('s2'))).get(); + expect(pending, isNotEmpty); + }); + + test('writes nothing and reports no change when all filled', () async { + await sites.createSite( + const DiveSite( + id: 's3', + name: 'n', + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + ), + ); + final before = await sites.getSiteById('s3'); + await (db.delete(db.syncRecords)..where((t) => t.recordId.equals('s3'))).go(); + + final changed = await sites.fillMissingLocationDetails('s3', found); + + expect(changed, isFalse); + expect(await sites.getSiteById('s3'), before); + final pending = await (db.select( + db.syncRecords, + )..where((t) => t.recordId.equals('s3'))).get(); + expect(pending, isEmpty, reason: 'no write, no sync record'); + }); + + test('returns false for an unknown site', () async { + expect(await sites.fillMissingLocationDetails('nope', found), isFalse); + }); +} +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `flutter test test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart` +Expected: compile error, `fillMissingLocationDetails` undefined. + +- [ ] **Step 7: Implement the repository method** + +In `lib/features/dive_sites/data/repositories/site_repository_impl.dart` add the imports `import 'package:submersion/core/services/geocoding/place_lookup.dart';` and `import 'package:submersion/features/dive_sites/domain/services/site_location_merge.dart';`, then before `applyImportedMetadata`: + +```dart + /// Fills whichever of country, region, city and body of water are still + /// empty on [siteId] from [found], leaving every other column untouched + /// (issue #1187). Returns true when a column was written. The row is + /// marked pending for sync only when something changed. + Future fillMissingLocationDetails( + String siteId, + PlaceLookup found, + ) async { + try { + return await _db.transaction(() async { + final row = await (_db.select( + _db.diveSites, + )..where((t) => t.id.equals(siteId))).getSingleOrNull(); + if (row == null) return false; + + final merged = mergeMissingLocationDetails( + current: SiteLocationDetails.ofSite(_mapRowToSite(row)), + found: found, + ); + if (merged == null) return false; + + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update(_db.diveSites)..where((t) => t.id.equals(siteId))) + .write( + DiveSitesCompanion( + country: merged.country == null + ? const Value.absent() + : Value(merged.country), + region: merged.region == null + ? const Value.absent() + : Value(merged.region), + city: merged.city == null + ? const Value.absent() + : Value(merged.city), + bodyOfWater: merged.bodyOfWater == null + ? const Value.absent() + : Value(merged.bodyOfWater), + updatedAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'diveSites', + recordId: siteId, + localUpdatedAt: now, + ); + return true; + }).then((changed) { + if (changed) SyncEventBus.notifyLocalChange(); + return changed; + }); + } catch (e, stackTrace) { + _log.error( + 'Failed to fill location details for site: $siteId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } +``` + +If `markRecordPending` opens its own transaction and Drift complains about nesting, move the `markRecordPending` call after the transaction block (still guarded by `changed`). + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart test/features/dive_sites/domain/services/site_location_merge_test.dart` +Expected: all pass. `flutter analyze`: `No issues found!` + +- [ ] **Step 9: Commit** + +```bash +dart format . && git add lib/features/dive_sites test/features/dive_sites && git commit -m "feat(sites): fill-empty merge rule and repository patch for location details (#1187)" +``` + +--- + +### Task 9: The site form fills town and body of water through one routine + +**Files:** +- Modify: `lib/features/dive_sites/presentation/widgets/location_picker_map.dart` (`PickedLocation` lines 16-30, `_confirmSelection` lines 96-114) +- Modify: `lib/features/dive_sites/presentation/pages/site_edit_page.dart` (`_geocodeSeed` lines 210-226, `_useMyLocation` lines 1372-1382, `_pickFromMap` lines 1424-1436) +- Test: `test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart` + +**Interfaces:** +- Consumes: `PlaceLookup`, `LocationResult.place` (Task 1), `mergeMissingLocationDetails` and `SiteLocationDetails` (Task 8). +- Produces: `PickedLocation({required double latitude, required double longitude, required PlaceLookup place})`. +- Produces (private to the page): `bool _applyPlaceLookup(PlaceLookup lookup, {required bool overwrite})`: writes the four controllers, returns whether any changed. + +- [ ] **Step 1: Write the failing test** + +`test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/pages/site_edit_page.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/shared/widgets/forms/suggestion_form_row.dart'; + +import '../../../../helpers/test_database.dart'; + +Finder _rowField(String label) => find.descendant( + of: find.ancestor( + of: find.text(label), + matching: find.byType(SuggestionFormRow), + ), + matching: find.byType(TextFormField), +); + +class _FakeLocationService implements LocationService { + _FakeLocationService(this.place); + + final PlaceLookup place; + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async => place; + + @override + Future getCurrentLocation({ + bool includeGeocoding = true, + Duration timeout = const Duration(seconds: 15), + String languageCode = LocationService.defaultLanguageCode, + }) async => LocationResult( + latitude: 47.027631, + longitude: 8.400640, + accuracy: 5, + country: place.country, + region: place.region, + locality: place.locality, + bodyOfWater: place.bodyOfWater, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const _weggis = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', +); + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpEditor( + WidgetTester tester, { + String? siteId, + GeoPoint? initialLocation, + PlaceLookup place = _weggis, + DiveSite? seeded, + }) async { + tester.view.physicalSize = const Size(900, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + allDiversProvider.overrideWith((_) async => const []), + shareByDefaultProvider.overrideWith((_) async => false), + validatedCurrentDiverIdProvider.overrideWith((_) async => null), + if (seeded != null) + siteProvider(seeded.id).overrideWith((_) async => seeded), + locationServiceProvider.overrideWithValue( + _FakeLocationService(place), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SiteEditPage( + siteId: siteId, + initialLocation: initialLocation, + embedded: true, + onSaved: (_) {}, + onCancel: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('Use my location fills town and body of water', (tester) async { + await pumpEditor(tester); + + await tester.tap(find.text('Use My Location')); + await tester.pumpAndSettle(); + + expect(find.text('Weggis'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsOneWidget); + expect(find.text('Switzerland'), findsOneWidget); + }); + + testWidgets('Use my location never overwrites a filled field', ( + tester, + ) async { + final repo = SiteRepository(); + final seeded = await repo.createSite( + const DiveSite(id: '', name: 'Hertenstein', city: 'Hertenstein'), + ); + await pumpEditor(tester, siteId: seeded.id, seeded: seeded); + + await tester.tap(find.text('Use My Location')); + await tester.pumpAndSettle(); + + expect(find.text('Hertenstein'), findsWidgets); + expect(find.text('Weggis'), findsNothing); + expect(find.text('Lake Lucerne'), findsOneWidget); + }); + + testWidgets('seeding from a dive fills town and body of water without ' + 'dirtying the form', (tester) async { + await pumpEditor( + tester, + initialLocation: const GeoPoint(47.027631, 8.400640), + ); + + expect(find.text('Weggis'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsOneWidget); + // Backing out of an untouched seeded form asks nothing. + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + }); +} +``` + +Confirm the Cancel button text and the unsaved-changes dialog behaviour against `test/features/dive_sites/presentation/pages/site_edit_seed_location_test.dart` and `site_edit_page_test.dart` (search for `Discard` / `Cancel`); adjust the last assertion to whatever the page actually shows. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart` +Expected: the first and third tests fail on `find.text('Weggis')` (country and region fill, town and lake do not). + +- [ ] **Step 3: Implement** + +`location_picker_map.dart`, replace `PickedLocation`: + +```dart +/// Result from the location picker +class PickedLocation { + final double latitude; + final double longitude; + + /// What the coordinates reverse-geocoded to. + final PlaceLookup place; + + const PickedLocation({ + required this.latitude, + required this.longitude, + required this.place, + }); +} +``` + +with `import 'package:submersion/core/services/geocoding/place_lookup.dart';`, and in `_confirmSelection`: + +```dart + Navigator.of(context).pop( + PickedLocation( + latitude: _selectedLocation!.latitude, + longitude: _selectedLocation!.longitude, + place: result, + ), + ); +``` + +`site_edit_page.dart`: add imports for `place_lookup.dart` and `site_location_merge.dart`, then add this method next to `_geocodeSeed`: + +```dart + /// Writes [lookup] into the country, region, city and body of water + /// fields. With [overwrite] false only empty fields change (the rule lives + /// in [mergeMissingLocationDetails]); with it true every found value + /// replaces the current one. Returns whether any field changed. Callers + /// decide whether that dirties the form. + bool _applyPlaceLookup(PlaceLookup lookup, {required bool overwrite}) { + final current = overwrite + ? const SiteLocationDetails() + : SiteLocationDetails( + country: _countryController.text, + region: _regionController.text, + city: _cityController.text, + bodyOfWater: _bodyOfWaterController.text, + ); + final merged = mergeMissingLocationDetails(current: current, found: lookup); + if (merged == null) return false; + + var changed = false; + void set(TextEditingController controller, String? value) { + if (value == null || controller.text == value) return; + controller.text = value; + changed = true; + } + + set(_countryController, merged.country); + set(_regionController, merged.region); + set(_cityController, merged.city); + set(_bodyOfWaterController, merged.bodyOfWater); + return changed; + } +``` + +Replace the body of `_geocodeSeed`'s `setState` with: + +```dart + setState(() { + _isApplyingInitialValues = true; + _applyPlaceLookup(result, overwrite: false); + _isApplyingInitialValues = false; + }); +``` + +In `_useMyLocation`, replace the two `if (_countryController...` / `if (_regionController...` blocks with `_applyPlaceLookup(result.place, overwrite: false);` (keep `_hasChanges = true;` because the coordinates changed). + +In `_pickFromMap`, replace the same two blocks with `_applyPlaceLookup(result.place, overwrite: false);`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_sites/presentation/pages` +Expected: all pass, including the unchanged Grand Turk and Bonaire tests. `flutter analyze`: `No issues found!` + +- [ ] **Step 5: Commit** + +```bash +dart format . && git add lib/features/dive_sites test/features/dive_sites && git commit -m "feat(sites): fill town and body of water from every coordinate source (#1187)" +``` + +--- + +### Task 10: "Look up from coordinates" on the site form + +**Files:** +- Modify: `lib/features/dive_sites/presentation/widgets/edit_sections/location_section.dart` (constructor, fields, the action row lines 96-123) +- Modify: `lib/features/dive_sites/presentation/pages/site_edit_page.dart` (`LocationSection` wiring lines 948-967; new `_lookupFromCoordinates` next to `_pickFromMap`) +- Modify: all 11 ARB files +- Test: `test/features/dive_sites/presentation/pages/site_edit_lookup_from_coordinates_test.dart` + +**Interfaces:** +- Consumes: `_applyPlaceLookup` (Task 9), `placeNameLanguageProvider` (Task 5), `reverseGeocode` (Task 1). +- Produces: `LocationSection.onLookupFromCoordinates` (`VoidCallback?`, null disables the button). +- Produces l10n keys: `diveSites_edit_gps_lookupFromCoordinates`, `diveSites_edit_snackbar_lookupNothingFound`, `diveSites_edit_snackbar_lookupFailed`, `diveSites_edit_lookupReplace_title`, `diveSites_edit_lookupReplace_body`, `diveSites_edit_lookupReplace_replace`, `diveSites_edit_lookupReplace_keep`; changed `diveSites_edit_gps_helperText`. + +- [ ] **Step 1: Add the strings to every ARB** + +`app_en.arb`: change `diveSites_edit_gps_helperText` to `"Choose a location method or look up the coordinates to auto-fill country, region, town and body of water"`, and add next to `diveSites_edit_gps_pickFromMap`: + +```json + "diveSites_edit_gps_lookupFromCoordinates": "Look up from coordinates", + "diveSites_edit_snackbar_lookupNothingFound": "No location details found for these coordinates", + "diveSites_edit_snackbar_lookupFailed": "Location lookup failed. Check your connection and try again.", + "diveSites_edit_lookupReplace_title": "Replace location details?", + "diveSites_edit_lookupReplace_body": "The lookup found different values for these fields:", + "diveSites_edit_lookupReplace_replace": "Replace", + "diveSites_edit_lookupReplace_keep": "Keep", +``` + +Translations (helperText / lookupFromCoordinates / lookupNothingFound / lookupFailed / replace_title / replace_body / replace / keep): + +- `de`: "Wählen Sie eine Standortmethode oder suchen Sie die Koordinaten, um Land, Region, Ort und Gewässer automatisch auszufüllen" / "Aus Koordinaten ermitteln" / "Keine Ortsangaben für diese Koordinaten gefunden" / "Ortssuche fehlgeschlagen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut." / "Ortsangaben ersetzen?" / "Die Suche hat für diese Felder andere Werte gefunden:" / "Ersetzen" / "Behalten" +- `es`: "Elige un método de ubicación o consulta las coordenadas para rellenar país, región, localidad y masa de agua" / "Consultar por coordenadas" / "No se encontraron datos de ubicación para estas coordenadas" / "La consulta de ubicación falló. Comprueba tu conexión e inténtalo de nuevo." / "¿Reemplazar los datos de ubicación?" / "La consulta encontró valores distintos para estos campos:" / "Reemplazar" / "Mantener" +- `fr`: "Choisissez une méthode de localisation ou recherchez les coordonnées pour remplir le pays, la région, la ville et le plan d'eau" / "Rechercher depuis les coordonnées" / "Aucune information de lieu trouvée pour ces coordonnées" / "La recherche de lieu a échoué. Vérifiez votre connexion et réessayez." / "Remplacer les informations de lieu ?" / "La recherche a trouvé des valeurs différentes pour ces champs :" / "Remplacer" / "Conserver" +- `it`: "Scegli un metodo di localizzazione o cerca le coordinate per compilare paese, regione, città e specchio d'acqua" / "Cerca dalle coordinate" / "Nessun dettaglio di località trovato per queste coordinate" / "Ricerca della località non riuscita. Controlla la connessione e riprova." / "Sostituire i dettagli di località?" / "La ricerca ha trovato valori diversi per questi campi:" / "Sostituisci" / "Mantieni" +- `nl`: "Kies een locatiemethode of zoek de coördinaten op om land, regio, plaats en water automatisch in te vullen" / "Opzoeken op coördinaten" / "Geen locatiegegevens gevonden voor deze coördinaten" / "Locatie opzoeken mislukt. Controleer je verbinding en probeer het opnieuw." / "Locatiegegevens vervangen?" / "Het opzoeken vond andere waarden voor deze velden:" / "Vervangen" / "Behouden" +- `pt`: "Escolha um método de localização ou consulte as coordenadas para preencher país, região, cidade e corpo de água" / "Consultar pelas coordenadas" / "Nenhum detalhe de localização encontrado para estas coordenadas" / "A consulta de localização falhou. Verifique a sua ligação e tente novamente." / "Substituir os detalhes de localização?" / "A consulta encontrou valores diferentes para estes campos:" / "Substituir" / "Manter" +- `hu`: "Válasszon helymeghatározási módot, vagy kérdezze le a koordinátákat az ország, régió, település és víztest automatikus kitöltéséhez" / "Lekérdezés a koordinátákból" / "Nem található helyadat ezekhez a koordinátákhoz" / "A helylekérdezés nem sikerült. Ellenőrizze a kapcsolatot, és próbálja újra." / "Lecseréli a helyadatokat?" / "A lekérdezés eltérő értékeket talált ezekhez a mezőkhöz:" / "Csere" / "Megtartás" +- `ar`: "اختر طريقة لتحديد الموقع أو ابحث عن الإحداثيات لملء البلد والمنطقة والبلدة والمسطح المائي تلقائيًا" / "البحث من الإحداثيات" / "لم يتم العثور على تفاصيل موقع لهذه الإحداثيات" / "فشل البحث عن الموقع. تحقق من الاتصال وحاول مرة أخرى." / "استبدال تفاصيل الموقع؟" / "عثر البحث على قيم مختلفة لهذه الحقول:" / "استبدال" / "إبقاء" +- `he`: "בחרו שיטת מיקום או חפשו את הקואורדינטות כדי למלא אוטומטית מדינה, אזור, עיר וגוף מים" / "חיפוש לפי קואורדינטות" / "לא נמצאו פרטי מיקום לקואורדינטות אלה" / "חיפוש המיקום נכשל. בדקו את החיבור ונסו שוב." / "להחליף את פרטי המיקום?" / "החיפוש מצא ערכים שונים לשדות אלה:" / "החלפה" / "שמירה" +- `zh`: "选择定位方式或根据坐标查找,以自动填写国家、地区、城镇和水域" / "根据坐标查找" / "未找到这些坐标的地点信息" / "地点查找失败。请检查网络连接后重试。" / "替换地点信息?" / "查找结果中以下字段的值不同:" / "替换" / "保留" + +Run `flutter gen-l10n`. + +- [ ] **Step 2: Write the failing test** + +`test/features/dive_sites/presentation/pages/site_edit_lookup_from_coordinates_test.dart`: copy the `_rowField`, `_FakeLocationService`, `_weggis`, `setUp`/`tearDown` and `pumpEditor` helpers from Task 9's test file verbatim, then: + +```dart + Future enterCoordinates(WidgetTester tester) async { + await tester.enterText( + find.widgetWithText(TextFormField, 'Latitude'), + '47.027631', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Longitude'), + '8.400640', + ); + await tester.pumpAndSettle(); + } + + testWidgets('the button is disabled until both coordinates parse', ( + tester, + ) async { + await pumpEditor(tester); + final button = find.widgetWithText(TextButton, 'Look up from coordinates'); + expect(tester.widget(button).onPressed, isNull); + + await enterCoordinates(tester); + expect(tester.widget(button).onPressed, isNotNull); + }); + + testWidgets('fills the empty fields and saves them', (tester) async { + await pumpEditor(tester); + await enterCoordinates(tester); + await tester.enterText(_rowField('Dive Site Name'), 'Hertenstein'); + + await tester.tap(find.text('Look up from coordinates')); + await tester.pumpAndSettle(); + + expect(find.text('Weggis'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + final saved = (await SiteRepository().getAllSites()).single; + expect(saved.city, 'Weggis'); + expect(saved.bodyOfWater, 'Lake Lucerne'); + expect(saved.country, 'Switzerland'); + }); + + testWidgets('offers to replace when nothing was empty and values differ', ( + tester, + ) async { + final repo = SiteRepository(); + final seeded = await repo.createSite( + const DiveSite( + id: '', + name: 'Hertenstein', + country: 'Schweiz', + region: 'Luzern', + city: 'Weggis', + bodyOfWater: 'Vierwaldstättersee', + location: GeoPoint(47.027631, 8.400640), + ), + ); + await pumpEditor(tester, siteId: seeded.id, seeded: seeded); + + await tester.tap(find.text('Look up from coordinates')); + await tester.pumpAndSettle(); + + expect(find.text('Replace location details?'), findsOneWidget); + // Only the differing fields are listed; the town is identical. + expect(find.textContaining('Lake Lucerne'), findsOneWidget); + expect(find.textContaining('Weggis'), findsNothing); + + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + expect(find.text('Lake Lucerne'), findsOneWidget); + expect(find.text('Vierwaldstättersee'), findsNothing); + }); + + testWidgets('Keep leaves the fields alone', (tester) async { + final repo = SiteRepository(); + final seeded = await repo.createSite( + const DiveSite( + id: '', + name: 'Hertenstein', + country: 'Schweiz', + region: 'Luzern', + city: 'Weggis', + bodyOfWater: 'Vierwaldstättersee', + location: GeoPoint(47.027631, 8.400640), + ), + ); + await pumpEditor(tester, siteId: seeded.id, seeded: seeded); + + await tester.tap(find.text('Look up from coordinates')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Keep')); + await tester.pumpAndSettle(); + + expect(find.text('Vierwaldstättersee'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsNothing); + }); + + testWidgets('says so when nothing was found', (tester) async { + await pumpEditor(tester, place: const PlaceLookup.empty()); + await enterCoordinates(tester); + + await tester.tap(find.text('Look up from coordinates')); + await tester.pumpAndSettle(); + + expect( + find.text('No location details found for these coordinates'), + findsOneWidget, + ); + }); + + testWidgets('reports an unreachable geocoder', (tester) async { + await pumpEditor(tester, place: const PlaceLookup.unavailable()); + await enterCoordinates(tester); + + await tester.tap(find.text('Look up from coordinates')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Location lookup failed. Check your connection and try again.', + ), + findsOneWidget, + ); + }); +``` + +The `Latitude` / `Longitude` labels come from `diveSites_edit_gps_latitude_label` and `_longitude_label`; check `grep -n "latitude_label\|longitude_label" lib/l10n/arb/app_en.arb` and `test/features/dive_sites/presentation/pages/site_edit_altitude_autofill_test.dart` for how coordinates are typed in tests, and copy that approach if `widgetWithText(TextFormField, ...)` does not match the `CoordinateFieldGroup` fields. + +- [ ] **Step 3: Run it to verify it fails** + +Run: `flutter test test/features/dive_sites/presentation/pages/site_edit_lookup_from_coordinates_test.dart` +Expected: every test fails, first on `find.widgetWithText(TextButton, 'Look up from coordinates')` (no such button). + +- [ ] **Step 4: Add the button to `LocationSection`** + +In `location_section.dart` add the constructor parameter `required this.onLookupFromCoordinates,` after `onPickFromMap`, the field `final VoidCallback? onLookupFromCoordinates;`, and after the `Pick from Map` `TextButton.icon` inside the `Row` (wrap the row's children in a `Wrap` with `spacing: 12` if three buttons overflow at 360 px; keep the existing two buttons' order): + +```dart + TextButton.icon( + onPressed: isGettingLocation + ? null + : onLookupFromCoordinates, + icon: const Icon(Icons.travel_explore, size: 16), + label: Text( + l10n.diveSites_edit_gps_lookupFromCoordinates, + ), + ), +``` + +Replace the `Row(` at line 98 with `Wrap(spacing: 12, runSpacing: 4, children: [` and drop the `const SizedBox(width: 12)` spacer. + +- [ ] **Step 5: Add the page logic** + +In `site_edit_page.dart`, wire the section: + +```dart + onLookupFromCoordinates: _parsedCoordinates() == null + ? null + : _lookupFromCoordinates, +``` + +and add next to `_pickFromMap`: + +```dart + /// The typed coordinates, or null while either field does not parse. + GeoPoint? _parsedCoordinates() { + final lat = double.tryParse(_latitudeController.text); + final lng = double.tryParse(_longitudeController.text); + if (lat == null || lng == null) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return GeoPoint(lat, lng); + } + + /// Explicit lookup for the typed coordinates (issue #1187). Fills empty + /// fields; when nothing was empty and the lookup differs, offers to + /// replace. Never runs on save. + Future _lookupFromCoordinates() async { + final point = _parsedCoordinates(); + if (point == null) return; + setState(() => _isGettingLocation = true); + try { + final lookup = await ref + .read(locationServiceProvider) + .reverseGeocode( + point.latitude, + point.longitude, + languageCode: ref.read(placeNameLanguageProvider), + ); + if (!mounted) return; + + if (lookup.networkFailed) { + _showLookupSnackBar(context.l10n.diveSites_edit_snackbar_lookupFailed); + return; + } + if (lookup.isEmpty) { + _showLookupSnackBar( + context.l10n.diveSites_edit_snackbar_lookupNothingFound, + ); + return; + } + + var changed = false; + setState(() { + changed = _applyPlaceLookup(lookup, overwrite: false); + if (changed) _hasChanges = true; + }); + if (changed) return; + + final differing = _differingLookupValues(lookup); + if (differing.isEmpty) return; + final replace = await _confirmReplaceLocationDetails(differing); + if (!mounted || !replace) return; + setState(() { + if (_applyPlaceLookup(lookup, overwrite: true)) _hasChanges = true; + }); + } finally { + if (mounted) setState(() => _isGettingLocation = false); + } + } + + void _showLookupSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + /// Field label to found value, for the fields whose found value is + /// non-blank and differs from what the form shows. + Map _differingLookupValues(PlaceLookup lookup) { + final l10n = context.l10n; + final out = {}; + void compare(String label, String current, String? found) { + if (found == null || found.trim().isEmpty) return; + if (current.trim() == found.trim()) return; + out[label] = found.trim(); + } + + compare(l10n.diveSites_edit_field_country_label, _countryController.text, lookup.country); + compare(l10n.diveSites_edit_field_region_label, _regionController.text, lookup.region); + compare(l10n.diveSites_edit_field_city_label, _cityController.text, lookup.locality); + compare(l10n.diveSites_edit_field_bodyOfWater_label, _bodyOfWaterController.text, lookup.bodyOfWater); + return out; + } + + Future _confirmReplaceLocationDetails( + Map differing, + ) async { + final l10n = context.l10n; + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.diveSites_edit_lookupReplace_title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.diveSites_edit_lookupReplace_body), + const SizedBox(height: 12), + for (final entry in differing.entries) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('${entry.key}: ${entry.value}'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.diveSites_edit_lookupReplace_keep), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.diveSites_edit_lookupReplace_replace), + ), + ], + ), + ); + return result ?? false; + } +``` + +The button's enabled state depends on the coordinate controllers, so the page must rebuild when they change: confirm `_latitudeController` and `_longitudeController` have `addListener(_onFieldChanged)` (line 122 onward) and that `_onFieldChanged` calls `setState`; if they do not, add listeners that call `setState(() {})` in `initState` and remove them in `dispose`. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_sites` +Expected: all pass. `flutter analyze`: `No issues found!` + +- [ ] **Step 7: Commit** + +```bash +dart format . && git add lib/features/dive_sites lib/l10n test/features/dive_sites && git commit -m "feat(sites): look up location details from typed coordinates (#1187)" +``` + +--- + +### Task 11: `SiteLocationBackfillService` + +**Files:** +- Create: `lib/features/dive_sites/domain/services/site_location_backfill_service.dart` +- Test: `test/features/dive_sites/domain/services/site_location_backfill_service_test.dart` + +**Interfaces:** +- Consumes: `SiteRepository.getAllSites({String? diverId})`, `SiteRepository.fillMissingLocationDetails` (Task 8), `LocationService.reverseGeocode` (Task 1), `PlaceLookup.networkFailed`. +- Produces: + +```dart +class BackfillSummary { + const BackfillSummary({required this.total, required this.updated, required this.unchanged, required this.failed, this.cancelled = false, this.offline = false}); + final int total; final int updated; final int unchanged; final int failed; final bool cancelled; final bool offline; +} +class SiteLocationBackfillService { + SiteLocationBackfillService({required SiteRepository sites, required LocationService location, required String languageCode}); + static bool needsLookup(DiveSite site); + Future> candidates({String? diverId}); + Future run({String? diverId, required void Function(int done, int total) onProgress, required bool Function() isCancelled}); +} +``` + +- [ ] **Step 1: Write the failing tests** + +`test/features/dive_sites/domain/services/site_location_backfill_service_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_backfill_service.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Answers each coordinate from a map; unknown coordinates come back empty. +class _MapLocationService implements LocationService { + _MapLocationService(this.answers, {this.offline = false, this.throwOn}); + + final Map answers; + final bool offline; + final String? throwOn; + final List asked = []; + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async { + final key = '$latitude,$longitude'; + asked.add(key); + if (offline) return const PlaceLookup.unavailable(); + if (key == throwOn) throw StateError('boom'); + return answers[key] ?? const PlaceLookup.empty(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late SiteRepository sites; + + setUp(() async { + await setUpTestDatabase(); + sites = SiteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + const weggis = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', + ); + + Future seed() async { + await sites.createSite( + const DiveSite( + id: 'empty', + name: 'Empty', + location: GeoPoint(47.0, 8.4), + ), + ); + await sites.createSite( + const DiveSite( + id: 'partial', + name: 'Partial', + country: 'Switzerland', + region: 'Lucerne', + location: GeoPoint(47.1, 8.5), + ), + ); + await sites.createSite( + const DiveSite( + id: 'full', + name: 'Full', + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + location: GeoPoint(47.2, 8.6), + ), + ); + await sites.createSite(const DiveSite(id: 'nogps', name: 'No GPS')); + } + + SiteLocationBackfillService service(LocationService location) => + SiteLocationBackfillService( + sites: sites, + location: location, + languageCode: 'en', + ); + + test('needsLookup wants coordinates and at least one empty field', () { + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite(id: '1', name: 'n', location: GeoPoint(1, 2)), + ), + isTrue, + ); + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite(id: '1', name: 'n'), + ), + isFalse, + ); + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite( + id: '1', + name: 'n', + location: GeoPoint(1, 2), + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + ), + ), + isFalse, + ); + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite( + id: '1', + name: 'n', + location: GeoPoint(1, 2), + country: 'a', + region: 'b', + city: ' ', + bodyOfWater: 'd', + ), + ), + isTrue, + reason: 'blank counts as empty', + ); + }); + + test('candidates skips full sites and sites without coordinates', () async { + await seed(); + final found = await service(_MapLocationService({})).candidates(); + expect(found.map((s) => s.id), unorderedEquals(['empty', 'partial'])); + }); + + test('run fills only empty fields and counts outcomes', () async { + await seed(); + final location = _MapLocationService({ + '47.0,8.4': weggis, + '47.1,8.5': const PlaceLookup(country: 'Schweiz', locality: 'Weggis'), + }); + final progress = <(int, int)>[]; + + final summary = await service(location).run( + onProgress: (done, total) => progress.add((done, total)), + isCancelled: () => false, + ); + + expect(summary.total, 2); + expect(summary.updated, 2); + expect(summary.unchanged, 0); + expect(summary.failed, 0); + expect(summary.cancelled, isFalse); + expect(progress, [(0, 2), (1, 2), (2, 2)]); + expect(location.asked, hasLength(2), reason: 'full and nogps not asked'); + + final partial = await sites.getSiteById('partial'); + expect(partial!.country, 'Switzerland', reason: 'kept'); + expect(partial.city, 'Weggis'); + final empty = await sites.getSiteById('empty'); + expect(empty!.bodyOfWater, 'Lake Lucerne'); + }); + + test('a lookup that finds nothing counts as unchanged', () async { + await seed(); + final summary = await service(_MapLocationService({})).run( + onProgress: (_, _) {}, + isCancelled: () => false, + ); + expect(summary.updated, 0); + expect(summary.unchanged, 2); + }); + + test('a throwing site is counted as failed and the run continues', () async { + await seed(); + final location = _MapLocationService({ + '47.1,8.5': weggis, + }, throwOn: '47.0,8.4'); + + final summary = await service(location).run( + onProgress: (_, _) {}, + isCancelled: () => false, + ); + + expect(summary.failed, 1); + expect(summary.updated, 1); + }); + + test('cancelling between sites stops the run', () async { + await seed(); + final location = _MapLocationService({'47.0,8.4': weggis}); + var calls = 0; + + final summary = await service(location).run( + onProgress: (_, _) {}, + isCancelled: () => calls++ >= 1, + ); + + expect(summary.cancelled, isTrue); + expect(location.asked, hasLength(1)); + }); + + test('an unreachable geocoder on the first site aborts as offline', () async { + await seed(); + final location = _MapLocationService({}, offline: true); + + final summary = await service(location).run( + onProgress: (_, _) {}, + isCancelled: () => false, + ); + + expect(summary.offline, isTrue); + expect(summary.failed, 0); + expect(location.asked, hasLength(1)); + }); + + test('run scopes candidates to the diver', () async { + await seed(); + final location = _MapLocationService({'47.0,8.4': weggis}); + + final summary = await service(location).run( + diverId: 'someone-else', + onProgress: (_, _) {}, + isCancelled: () => false, + ); + + expect(summary.total, 0); + expect(location.asked, isEmpty); + }); +} +``` + +For the last test, check how `getAllSites(diverId:)` treats sites whose `diverId` is null (`sed -n 47,66p lib/features/dive_sites/data/repositories/site_repository_impl.dart`); if null-diver sites are returned for every diver, seed the sites with `diverId: 'diver-1'` after inserting a `Divers` row, and expect `total 0` for `'someone-else'`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/features/dive_sites/domain/services/site_location_backfill_service_test.dart` +Expected: compile error, file missing. + +- [ ] **Step 3: Implement the service** + +`lib/features/dive_sites/domain/services/site_location_backfill_service.dart`: + +```dart +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +/// Outcome of one backfill run. +class BackfillSummary { + const BackfillSummary({ + required this.total, + required this.updated, + required this.unchanged, + required this.failed, + this.cancelled = false, + this.offline = false, + }); + + final int total; + final int updated; + final int unchanged; + final int failed; + final bool cancelled; + + /// The geocoder could not be reached on the first request, so the run + /// stopped before collecting one failure per site. + final bool offline; +} + +bool _isBlank(String? value) => value == null || value.trim().isEmpty; + +/// Fills empty country, region, town and body of water for every site that +/// has coordinates (issue #1187). Only empty columns are ever written; the +/// rule itself lives in `mergeMissingLocationDetails` behind +/// [SiteRepository.fillMissingLocationDetails]. Request spacing is the +/// location service's concern. +class SiteLocationBackfillService { + SiteLocationBackfillService({ + required SiteRepository sites, + required LocationService location, + required String languageCode, + }) : _sites = sites, + _location = location, + _languageCode = languageCode; + + final SiteRepository _sites; + final LocationService _location; + final String _languageCode; + static final _log = LoggerService.forClass(SiteLocationBackfillService); + + /// A site the run would look up: coordinates present and at least one of + /// the four fields blank. + static bool needsLookup(DiveSite site) => + site.location != null && + (_isBlank(site.country) || + _isBlank(site.region) || + _isBlank(site.city) || + _isBlank(site.bodyOfWater)); + + Future> candidates({String? diverId}) async { + final all = await _sites.getAllSites(diverId: diverId); + return all.where(needsLookup).toList(growable: false); + } + + Future run({ + String? diverId, + required void Function(int done, int total) onProgress, + required bool Function() isCancelled, + }) async { + final targets = await candidates(diverId: diverId); + final total = targets.length; + var updated = 0; + var unchanged = 0; + var failed = 0; + var done = 0; + onProgress(done, total); + + for (final site in targets) { + if (isCancelled()) { + return BackfillSummary( + total: total, + updated: updated, + unchanged: unchanged, + failed: failed, + cancelled: true, + ); + } + final point = site.location!; + try { + final lookup = await _location.reverseGeocode( + point.latitude, + point.longitude, + languageCode: _languageCode, + ); + if (lookup.networkFailed) { + if (done == 0) { + return BackfillSummary( + total: total, + updated: updated, + unchanged: unchanged, + failed: failed, + offline: true, + ); + } + failed++; + } else if (await _sites.fillMissingLocationDetails(site.id, lookup)) { + updated++; + } else { + unchanged++; + } + } catch (e, stackTrace) { + _log.warning( + 'Backfill failed for site ${site.id}: $e', + error: e, + stackTrace: stackTrace, + ); + failed++; + } + done++; + onProgress(done, total); + } + + return BackfillSummary( + total: total, + updated: updated, + unchanged: unchanged, + failed: failed, + ); + } +} +``` + +If `LoggerService.warning` does not accept `error:`/`stackTrace:` named arguments (check `grep -n "void warning" lib/core/services/logger_service.dart`), call `_log.warning('Backfill failed for site ${site.id}: $e')` instead. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_sites/domain/services/site_location_backfill_service_test.dart` +Expected: all pass. `flutter analyze`: `No issues found!` + +- [ ] **Step 5: Commit** + +```bash +dart format . && git add lib/features/dive_sites test/features/dive_sites && git commit -m "feat(sites): backfill service for missing location details (#1187)" +``` + +--- + +### Task 12: Bulk "Fill in missing location details" from the sites list + +**Files:** +- Create: `lib/features/dive_sites/presentation/providers/site_location_backfill_provider.dart` +- Create: `lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart` +- Modify: `lib/features/dive_sites/presentation/widgets/site_list_content.dart` (both `PopupMenuButton` blocks, lines 515-545 and 777-800) +- Modify: `lib/features/dive_sites/presentation/pages/site_list_page.dart` (`PopupMenuButton` lines 173-197) +- Modify: all 11 ARB files +- Test: `test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart`, `test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart` + +**Interfaces:** +- Consumes: `SiteLocationBackfillService`, `BackfillSummary` (Task 11), `siteRepositoryProvider`, `locationServiceProvider`, `placeNameLanguageProvider`, `validatedCurrentDiverIdProvider`, `siteListNotifierProvider`. +- Produces: + +```dart +sealed class BackfillState { const BackfillState(); } +class BackfillIdle extends BackfillState { const BackfillIdle(); } +class BackfillRunning extends BackfillState { const BackfillRunning({required this.done, required this.total}); final int done; final int total; } +class BackfillFinished extends BackfillState { const BackfillFinished(this.summary); final BackfillSummary summary; } +class SiteLocationBackfillNotifier extends StateNotifier { Future countCandidates(); Future start(); void cancel(); void reset(); } +final siteLocationBackfillProvider = StateNotifierProvider; +Future showSiteLocationBackfillFlow(BuildContext context, WidgetRef ref); +``` + +- Produces l10n keys: `diveSites_list_menu_fillLocationDetails`, `diveSites_backfill_confirm_title`, `diveSites_backfill_confirm_body` (placeholders `count`, `minutes`), `diveSites_backfill_confirm_start`, `diveSites_backfill_nothingToFill`, `diveSites_backfill_progress_title`, `diveSites_backfill_progress_count` (placeholders `done`, `total`), `diveSites_backfill_cancel`, `diveSites_backfill_summary` (placeholders `updated`, `unchanged`, `failed`), `diveSites_backfill_offline`. + +- [ ] **Step 1: Add the strings to every ARB** + +`app_en.arb`, next to `diveSites_list_menu_select`: + +```json + "diveSites_list_menu_fillLocationDetails": "Fill in missing location details", + "diveSites_backfill_confirm_title": "Fill in missing location details?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 site with coordinates has an empty country, region, town or body of water.} other{{count} sites with coordinates have an empty country, region, town or body of water.}} Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about {minutes} minutes.", + "@diveSites_backfill_confirm_body": { + "placeholders": { + "count": { + "type": "int" + }, + "minutes": { + "type": "int" + } + } + }, + "diveSites_backfill_confirm_start": "Start", + "diveSites_backfill_nothingToFill": "Every site with coordinates already has its location details.", + "diveSites_backfill_progress_title": "Filling in location details", + "diveSites_backfill_progress_count": "{done} of {total}", + "@diveSites_backfill_progress_count": { + "placeholders": { + "done": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "diveSites_backfill_cancel": "Cancel", + "diveSites_backfill_summary": "Updated {updated}, unchanged {unchanged}, failed {failed}", + "@diveSites_backfill_summary": { + "placeholders": { + "updated": { + "type": "int" + }, + "unchanged": { + "type": "int" + }, + "failed": { + "type": "int" + } + } + }, + "diveSites_backfill_offline": "Location lookup is unavailable. Check your connection and try again.", +``` + +Translations, in the order menu / confirm_title / confirm_body / confirm_start / nothingToFill / progress_title / progress_count / cancel / summary / offline. Keep the ICU plural and placeholder syntax exactly as in English. + +- `de`: "Fehlende Ortsangaben ergänzen" / "Fehlende Ortsangaben ergänzen?" / "{count, plural, =1{1 Tauchplatz mit Koordinaten hat kein Land, keine Region, keinen Ort oder kein Gewässer.} other{{count} Tauchplätze mit Koordinaten haben kein Land, keine Region, keinen Ort oder kein Gewässer.}} Submersion sucht jeden auf OpenStreetMap und füllt nur die leeren Felder aus. Das dauert etwa {minutes} Minuten." / "Starten" / "Alle Tauchplätze mit Koordinaten haben bereits ihre Ortsangaben." / "Ortsangaben werden ergänzt" / "{done} von {total}" / "Abbrechen" / "Aktualisiert {updated}, unverändert {unchanged}, fehlgeschlagen {failed}" / "Die Ortssuche ist nicht verfügbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut." +- `es`: "Completar datos de ubicación que faltan" / "¿Completar los datos de ubicación que faltan?" / "{count, plural, =1{1 punto de buceo con coordenadas no tiene país, región, localidad o masa de agua.} other{{count} puntos de buceo con coordenadas no tienen país, región, localidad o masa de agua.}} Submersion consultará cada uno en OpenStreetMap y rellenará solo los campos vacíos. Tarda unos {minutes} minutos." / "Iniciar" / "Todos los puntos de buceo con coordenadas ya tienen sus datos de ubicación." / "Completando datos de ubicación" / "{done} de {total}" / "Cancelar" / "Actualizados {updated}, sin cambios {unchanged}, fallidos {failed}" / "La consulta de ubicación no está disponible. Comprueba tu conexión e inténtalo de nuevo." +- `fr`: "Compléter les informations de lieu manquantes" / "Compléter les informations de lieu manquantes ?" / "{count, plural, =1{1 site avec coordonnées n'a pas de pays, de région, de ville ou de plan d'eau.} other{{count} sites avec coordonnées n'ont pas de pays, de région, de ville ou de plan d'eau.}} Submersion recherchera chacun sur OpenStreetMap et ne remplira que les champs vides. Cela prend environ {minutes} minutes." / "Démarrer" / "Tous les sites avec coordonnées ont déjà leurs informations de lieu." / "Complément des informations de lieu" / "{done} sur {total}" / "Annuler" / "Mis à jour {updated}, inchangés {unchanged}, échoués {failed}" / "La recherche de lieu est indisponible. Vérifiez votre connexion et réessayez." +- `it`: "Completa i dettagli di località mancanti" / "Completare i dettagli di località mancanti?" / "{count, plural, =1{1 sito con coordinate non ha paese, regione, città o specchio d'acqua.} other{{count} siti con coordinate non hanno paese, regione, città o specchio d'acqua.}} Submersion cercherà ciascuno su OpenStreetMap e compilerà solo i campi vuoti. Richiede circa {minutes} minuti." / "Avvia" / "Tutti i siti con coordinate hanno già i dettagli di località." / "Completamento dei dettagli di località" / "{done} di {total}" / "Annulla" / "Aggiornati {updated}, invariati {unchanged}, falliti {failed}" / "La ricerca della località non è disponibile. Controlla la connessione e riprova." +- `nl`: "Ontbrekende locatiegegevens aanvullen" / "Ontbrekende locatiegegevens aanvullen?" / "{count, plural, =1{1 duikstek met coördinaten heeft geen land, regio, plaats of water.} other{{count} duikstekken met coördinaten hebben geen land, regio, plaats of water.}} Submersion zoekt elke stek op via OpenStreetMap en vult alleen lege velden in. Dit duurt ongeveer {minutes} minuten." / "Starten" / "Elke duikstek met coördinaten heeft al locatiegegevens." / "Locatiegegevens aanvullen" / "{done} van {total}" / "Annuleren" / "Bijgewerkt {updated}, ongewijzigd {unchanged}, mislukt {failed}" / "Locatie opzoeken is niet beschikbaar. Controleer je verbinding en probeer het opnieuw." +- `pt`: "Preencher detalhes de localização em falta" / "Preencher os detalhes de localização em falta?" / "{count, plural, =1{1 local com coordenadas não tem país, região, cidade ou corpo de água.} other{{count} locais com coordenadas não têm país, região, cidade ou corpo de água.}} O Submersion consultará cada um no OpenStreetMap e preencherá apenas os campos vazios. Demora cerca de {minutes} minutos." / "Iniciar" / "Todos os locais com coordenadas já têm os seus detalhes de localização." / "A preencher detalhes de localização" / "{done} de {total}" / "Cancelar" / "Atualizados {updated}, inalterados {unchanged}, falhados {failed}" / "A consulta de localização não está disponível. Verifique a sua ligação e tente novamente." +- `hu`: "Hiányzó helyadatok kitöltése" / "Kitölti a hiányzó helyadatokat?" / "{count, plural, =1{1 koordinátával rendelkező merülőhelynek üres az országa, régiója, települése vagy víztestje.} other{{count} koordinátával rendelkező merülőhelynek üres az országa, régiója, települése vagy víztestje.}} A Submersion mindegyiket lekérdezi az OpenStreetMapról, és csak az üres mezőket tölti ki. Ez körülbelül {minutes} percet vesz igénybe." / "Indítás" / "Minden koordinátával rendelkező merülőhelynek megvannak a helyadatai." / "Helyadatok kitöltése" / "{done} / {total}" / "Mégse" / "Frissítve {updated}, változatlan {unchanged}, sikertelen {failed}" / "A helylekérdezés nem érhető el. Ellenőrizze a kapcsolatot, és próbálja újra." +- `ar`: "إكمال تفاصيل الموقع الناقصة" / "إكمال تفاصيل الموقع الناقصة؟" / "{count, plural, =1{موقع غوص واحد له إحداثيات ينقصه البلد أو المنطقة أو البلدة أو المسطح المائي.} other{{count} مواقع غوص لها إحداثيات ينقصها البلد أو المنطقة أو البلدة أو المسطح المائي.}} سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو {minutes} دقائق." / "بدء" / "كل مواقع الغوص التي لها إحداثيات لديها تفاصيل الموقع بالفعل." / "جارٍ إكمال تفاصيل الموقع" / "{done} من {total}" / "إلغاء" / "تم تحديث {updated}، بدون تغيير {unchanged}، فشل {failed}" / "البحث عن الموقع غير متاح. تحقق من الاتصال وحاول مرة أخرى." +- `he`: "השלמת פרטי מיקום חסרים" / "להשלים פרטי מיקום חסרים?" / "{count, plural, =1{לאתר אחד עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.} other{ל-{count} אתרים עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.}} Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך כ-{minutes} דקות." / "התחלה" / "לכל האתרים עם קואורדינטות כבר יש פרטי מיקום." / "משלים פרטי מיקום" / "{done} מתוך {total}" / "ביטול" / "עודכנו {updated}, ללא שינוי {unchanged}, נכשלו {failed}" / "חיפוש המיקום אינו זמין. בדקו את החיבור ונסו שוב." +- `zh`: "补全缺失的地点信息" / "补全缺失的地点信息?" / "{count, plural, =1{1 个有坐标的潜点缺少国家、地区、城镇或水域。} other{{count} 个有坐标的潜点缺少国家、地区、城镇或水域。}} Submersion 将在 OpenStreetMap 上逐个查找,并仅填写空白字段。大约需要 {minutes} 分钟。" / "开始" / "所有有坐标的潜点都已有地点信息。" / "正在补全地点信息" / "{done} / {total}" / "取消" / "已更新 {updated},未变 {unchanged},失败 {failed}" / "地点查找不可用。请检查网络连接后重试。" + +Run `flutter gen-l10n`. + +- [ ] **Step 2: Write the failing provider test** + +`test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_location_backfill_provider.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Blocks each lookup until [release] is called, so a test can observe the +/// running state and cancel mid-run. +class _GatedLocationService implements LocationService { + final List> gates = []; + final List languages = []; + + void release() => gates.removeAt(0).complete(); + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async { + languages.add(languageCode); + final gate = Completer(); + gates.add(gate); + await gate.future; + return const PlaceLookup(country: 'Switzerland', locality: 'Weggis'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late ProviderContainer container; + late SiteRepository sites; + late _GatedLocationService location; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + sites = SiteRepository(); + location = _GatedLocationService(); + container = ProviderContainer( + overrides: [ + siteRepositoryProvider.overrideWithValue(sites), + sharedPreferencesProvider.overrideWithValue(prefs), + validatedCurrentDiverIdProvider.overrideWith((ref) async => null), + locationServiceProvider.overrideWithValue(location), + ], + ); + await sites.createSite( + const DiveSite(id: 'a', name: 'A', location: GeoPoint(47.0, 8.4)), + ); + await sites.createSite( + const DiveSite(id: 'b', name: 'B', location: GeoPoint(47.1, 8.5)), + ); + }); + + tearDown(() async { + container.dispose(); + await tearDownTestDatabase(); + }); + + test('starts idle and counts candidates', () async { + expect(container.read(siteLocationBackfillProvider), isA()); + final notifier = container.read(siteLocationBackfillProvider.notifier); + expect(await notifier.countCandidates(), 2); + }); + + test('reports progress while running and finishes with a summary', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + final run = notifier.start(); + await Future.delayed(Duration.zero); + + expect( + container.read(siteLocationBackfillProvider), + isA() + .having((s) => s.done, 'done', 0) + .having((s) => s.total, 'total', 2), + ); + + location.release(); + await Future.delayed(Duration.zero); + location.release(); + await run; + + final state = container.read(siteLocationBackfillProvider); + expect(state, isA()); + expect((state as BackfillFinished).summary.updated, 2); + expect((await sites.getSiteById('a'))!.city, 'Weggis'); + }); + + test('a second start while running is a no-op', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + final first = notifier.start(); + await Future.delayed(Duration.zero); + await notifier.start(); + expect(location.gates, hasLength(1), reason: 'no second run began'); + + location.release(); + await Future.delayed(Duration.zero); + location.release(); + await first; + }); + + test('cancel stops after the current site', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + final run = notifier.start(); + await Future.delayed(Duration.zero); + + notifier.cancel(); + location.release(); + await run; + + final state = container.read(siteLocationBackfillProvider); + expect((state as BackfillFinished).summary.cancelled, isTrue); + expect(location.gates, isEmpty); + }); + + test('reset returns to idle', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + final run = notifier.start(); + await Future.delayed(Duration.zero); + location.release(); + await Future.delayed(Duration.zero); + location.release(); + await run; + + notifier.reset(); + expect(container.read(siteLocationBackfillProvider), isA()); + }); + + test('looks up in the place name language', () async { + await container + .read(settingsProvider.notifier) + .setPlaceNameLanguage('de'); + final notifier = container.read(siteLocationBackfillProvider.notifier); + final run = notifier.start(); + await Future.delayed(Duration.zero); + location.release(); + await Future.delayed(Duration.zero); + location.release(); + await run; + expect(location.languages, ['de', 'de']); + }); +} +``` + +If `settingsProvider` in this container needs more overrides to save (it writes to the diver settings repository), replace the last test's first line with `container.read(settingsProvider.notifier).state = const AppSettings(placeNameLanguage: 'de');` guarded by whatever `SettingsNotifier` exposes for tests (`grep -n "visibleForTesting" lib/features/settings/presentation/providers/settings_providers.dart`). + +- [ ] **Step 3: Run it to verify it fails** + +Run: `flutter test test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart` +Expected: compile error, provider file missing. + +- [ ] **Step 4: Implement the provider** + +`lib/features/dive_sites/presentation/providers/site_location_backfill_provider.dart`: + +```dart +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_backfill_service.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// Progress of the bulk location-details backfill (issue #1187). +sealed class BackfillState { + const BackfillState(); +} + +class BackfillIdle extends BackfillState { + const BackfillIdle(); +} + +class BackfillRunning extends BackfillState { + const BackfillRunning({required this.done, required this.total}); + final int done; + final int total; +} + +class BackfillFinished extends BackfillState { + const BackfillFinished(this.summary); + final BackfillSummary summary; +} + +/// Owns one backfill run at a time so the progress dialog can be rebuilt, +/// dismissed and reopened without losing the run. +class SiteLocationBackfillNotifier extends StateNotifier { + SiteLocationBackfillNotifier(this._ref) : super(const BackfillIdle()); + + final Ref _ref; + bool _cancelRequested = false; + + SiteLocationBackfillService _service() => SiteLocationBackfillService( + sites: _ref.read(siteRepositoryProvider), + location: _ref.read(locationServiceProvider), + languageCode: _ref.read(placeNameLanguageProvider), + ); + + Future _diverId() => + _ref.read(validatedCurrentDiverIdProvider.future); + + /// How many sites a run would look up. + Future countCandidates() async => + (await _service().candidates(diverId: await _diverId())).length; + + /// Starts a run unless one is already running. + Future start() async { + if (state is BackfillRunning) return; + _cancelRequested = false; + state = const BackfillRunning(done: 0, total: 0); + final summary = await _service().run( + diverId: await _diverId(), + onProgress: (done, total) { + if (mounted) state = BackfillRunning(done: done, total: total); + }, + isCancelled: () => _cancelRequested, + ); + if (!mounted) return; + state = BackfillFinished(summary); + if (summary.updated > 0) { + await _ref.read(siteListNotifierProvider.notifier).refresh(); + } + } + + void cancel() => _cancelRequested = true; + + void reset() => state = const BackfillIdle(); +} + +final siteLocationBackfillProvider = + StateNotifierProvider( + (ref) => SiteLocationBackfillNotifier(ref), + ); +``` + +Check `lib/core/providers/provider.dart` exports `Ref` and `StateNotifier`; if not, import `package:flutter_riverpod/flutter_riverpod.dart` the way `site_providers.dart` does. + +- [ ] **Step 5: Run the provider test to verify it passes** + +Run: `flutter test test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart` +Expected: all pass. + +- [ ] **Step 6: Write the failing dialog test** + +`test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_backfill_service.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_location_backfill_provider.dart'; +import 'package:submersion/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// A scripted notifier so the dialog can be driven without a database or +/// network: [candidates] answers the count, [start] walks [script]. +class _ScriptedBackfill extends StateNotifier + implements SiteLocationBackfillNotifier { + _ScriptedBackfill({required this.candidates, required this.script}) + : super(const BackfillIdle()); + + final int candidates; + final List script; + int startCalls = 0; + bool cancelled = false; + + @override + Future countCandidates() async => candidates; + + @override + Future start() async { + startCalls++; + for (final s in script) { + await Future.delayed(const Duration(milliseconds: 10)); + state = s; + } + } + + @override + void cancel() => cancelled = true; + + @override + void reset() => state = const BackfillIdle(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + Widget host(_ScriptedBackfill notifier) => ProviderScope( + overrides: [siteLocationBackfillProvider.overrideWith((_) => notifier)], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => showSiteLocationBackfillFlow(context, ref), + child: const Text('go'), + ), + ), + ), + ), + ); + + testWidgets('says so when there is nothing to fill', (tester) async { + final notifier = _ScriptedBackfill(candidates: 0, script: const []); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Every site with coordinates already has its location details.', + ), + findsOneWidget, + ); + expect(notifier.startCalls, 0); + }); + + testWidgets('confirms with the count and estimate, then shows progress and ' + 'a summary', (tester) async { + final notifier = _ScriptedBackfill( + candidates: 104, + script: const [ + BackfillRunning(done: 0, total: 104), + BackfillRunning(done: 12, total: 104), + BackfillFinished( + BackfillSummary(total: 104, updated: 90, unchanged: 13, failed: 1), + ), + ], + ); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect(find.text('Fill in missing location details?'), findsOneWidget); + expect(find.textContaining('104 sites with coordinates'), findsOneWidget); + expect(find.textContaining('about 4 minutes'), findsOneWidget); + + await tester.tap(find.text('Start')); + await tester.pump(const Duration(milliseconds: 15)); + expect(find.text('Filling in location details'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text('12 of 104'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('Filling in location details'), findsNothing); + expect(find.text('Updated 90, unchanged 13, failed 1'), findsOneWidget); + }); + + testWidgets('cancel asks the notifier to stop', (tester) async { + final notifier = _ScriptedBackfill( + candidates: 3, + script: const [ + BackfillRunning(done: 0, total: 3), + BackfillFinished( + BackfillSummary( + total: 3, + updated: 1, + unchanged: 0, + failed: 0, + cancelled: true, + ), + ), + ], + ); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start')); + await tester.pump(const Duration(milliseconds: 15)); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(notifier.cancelled, isTrue); + }); + + testWidgets('an offline run shows the offline message', (tester) async { + final notifier = _ScriptedBackfill( + candidates: 3, + script: const [ + BackfillRunning(done: 0, total: 3), + BackfillFinished( + BackfillSummary( + total: 3, + updated: 0, + unchanged: 0, + failed: 0, + offline: true, + ), + ), + ], + ); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Location lookup is unavailable. Check your connection and try again.', + ), + findsOneWidget, + ); + }); +} +``` + +- [ ] **Step 7: Run it to verify it fails** + +Run: `flutter test test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart` +Expected: compile error, dialog file missing. + +- [ ] **Step 8: Implement the flow** + +`lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_location_backfill_provider.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Seconds per site: two Nominatim requests, one second apart. +const int _secondsPerSite = 2; + +/// The bulk "fill in missing location details" flow (issue #1187): +/// count, confirm, run with a progress dialog, summarise in a snackbar. +Future showSiteLocationBackfillFlow( + BuildContext context, + WidgetRef ref, +) async { + final l10n = context.l10n; + final notifier = ref.read(siteLocationBackfillProvider.notifier); + final messenger = ScaffoldMessenger.of(context); + + final count = await notifier.countCandidates(); + if (!context.mounted) return; + if (count == 0) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.diveSites_backfill_nothingToFill)), + ); + return; + } + + final minutes = ((count * _secondsPerSite) / 60).ceil(); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.diveSites_backfill_confirm_title), + content: Text(l10n.diveSites_backfill_confirm_body(count, minutes)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.diveSites_backfill_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.diveSites_backfill_confirm_start), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + notifier.reset(); + final run = notifier.start(); + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _BackfillProgressDialog(), + ); + await run; + if (!context.mounted) return; + + final state = ref.read(siteLocationBackfillProvider); + if (state is! BackfillFinished) return; + final summary = state.summary; + messenger.showSnackBar( + SnackBar( + content: Text( + summary.offline + ? l10n.diveSites_backfill_offline + : l10n.diveSites_backfill_summary( + summary.updated, + summary.unchanged, + summary.failed, + ), + ), + ), + ); + notifier.reset(); +} + +/// Watches the run and closes itself when it finishes. +class _BackfillProgressDialog extends ConsumerWidget { + const _BackfillProgressDialog(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final state = ref.watch(siteLocationBackfillProvider); + + ref.listen(siteLocationBackfillProvider, (_, next) { + if (next is BackfillFinished && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }); + + final running = state is BackfillRunning ? state : null; + final total = running?.total ?? 0; + final done = running?.done ?? 0; + return AlertDialog( + title: Text(l10n.diveSites_backfill_progress_title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator(value: total == 0 ? null : done / total), + const SizedBox(height: 12), + Text(l10n.diveSites_backfill_progress_count(done, total)), + ], + ), + actions: [ + TextButton( + onPressed: () => + ref.read(siteLocationBackfillProvider.notifier).cancel(), + child: Text(l10n.diveSites_backfill_cancel), + ), + ], + ); + } +} +``` + +If `BackfillFinished` arrives before the progress dialog is first built (a run with zero sites cannot happen here, but a very fast run can), the `ref.listen` never fires; guard by checking `if (state is BackfillFinished)` at the top of `build` and scheduling `Navigator.of(context).pop()` in a post-frame callback. + +- [ ] **Step 9: Add the menu items** + +In `lib/features/dive_sites/presentation/widgets/site_list_content.dart`, in both `PopupMenuButton` blocks (lines 515 and 777): add to `onSelected` + +```dart + } else if (value == 'fill_location_details') { + showSiteLocationBackfillFlow(context, ref); +``` + +and add to the item list, after the `'import'` item (find it with `grep -n "value: 'import'" lib/features/dive_sites/presentation/widgets/site_list_content.dart`): + +```dart + PopupMenuItem( + value: 'fill_location_details', + child: ListTile( + leading: const Icon(Icons.travel_explore), + title: Text( + context.l10n + .diveSites_list_menu_fillLocationDetails, + ), + contentPadding: EdgeInsets.zero, + ), + ), +``` + +with `import 'package:submersion/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart';`. + +In `lib/features/dive_sites/presentation/pages/site_list_page.dart` (`PopupMenuButton` at line 173), add the same `onSelected` branch and the same `PopupMenuItem` after the view-mode items, preceded by `const PopupMenuDivider(),`, with the same import. + +- [ ] **Step 10: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_sites` +Expected: all pass. `flutter analyze`: `No issues found!` + +- [ ] **Step 11: Commit** + +```bash +dart format . && git add lib/features/dive_sites lib/l10n test/features/dive_sites && git commit -m "feat(sites): bulk fill of missing location details from the sites list (#1187)" +``` + +--- + +### Task 13: Whole-tree verification + +**Files:** +- No new files. Fixes only where the checks below fail. + +- [ ] **Step 1: Format and analyze** + +Run: `dart format . && flutter analyze` +Expected: `Formatted N files (0 changed)` and `No issues found!`. If format changed anything, commit it as `style: format`. + +- [ ] **Step 2: Confirm nothing still uses the old geocode shape** + +Run: `grep -rn "String? locality})\|_geocoderLocale\|accept-language=en" lib/ test/` +Expected: no output. Fix and commit anything found. + +- [ ] **Step 3: Confirm every locale has every new key** + +Run: + +```bash +for k in settings_placeNameLanguage_title settings_placeNameLanguage_subtitle diveSites_edit_gps_lookupFromCoordinates diveSites_edit_snackbar_lookupNothingFound diveSites_edit_snackbar_lookupFailed diveSites_edit_lookupReplace_title diveSites_edit_lookupReplace_body diveSites_edit_lookupReplace_replace diveSites_edit_lookupReplace_keep diveSites_list_menu_fillLocationDetails diveSites_backfill_confirm_title diveSites_backfill_confirm_body diveSites_backfill_confirm_start diveSites_backfill_nothingToFill diveSites_backfill_progress_title diveSites_backfill_progress_count diveSites_backfill_cancel diveSites_backfill_summary diveSites_backfill_offline; do for f in lib/l10n/arb/app_*.arb; do grep -q "\"$k\"" "$f" || echo "MISSING $k in $f"; done; done +``` + +Expected: no output. + +- [ ] **Step 4: Run the full suite once** + +Run: `flutter test 2>&1 | tail -3` +Expected: `All tests passed!`. If exactly one file unrelated to this branch fails, rerun that file alone; a repeat failure is real and must be fixed before finishing. Do not run the full suite twice on a green result. + +- [ ] **Step 5: Final commit and hand-off** + +If any step above changed files, `dart format . && git add -A && git commit -m "chore(sites): verification fixes for location details from coordinates (#1187)"`. Then report: the branch name, the commit list (`git log --oneline origin/main..HEAD`), and the full-suite line. Do not push; the user opens the PR. From b0102363905d620f5522321f270cf4891ca84ea5 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:55:50 -0400 Subject: [PATCH 047/122] fix(buddies): sort shared dives by dive date and show the year (#982) The buddy detail page's "Shared Dives" preview showed an arbitrary five dives rather than the five most recent, and its date labels omitted the year. Sorting: getDiveIdsForBuddy ordered by dive_buddies.created_at, the timestamp of the buddy link row (import/edit order), not the dive's own date. divesForBuddyProvider then truncated to the first five ids and only afterwards sorted those five by date. Truncating an unordered list and sorting the survivors yields five arbitrary dives in neat order, so a dive from a previous year could occupy a preview slot while the most recent dive named in the stats card above was dropped entirely. The query now joins dives and orders by COALESCE(entry_time, dive_date_time) DESC, dive_number DESC, matching DiveRepository.getAllDives so the preview agrees with the dive list. The Dart sort in the provider was aligned to the same key. The join also drops links whose dive row no longer exists, which previously burned a preview slot on an id that could not be hydrated. Year: the shared dives list formatted dates with DateFormat.MMMd(), so a list spanning several years rendered ambiguous labels like "Mar 28". It now uses DateFormat.yMMMd(), matching the statistics card above it. Tests: three repository ordering tests (link order, entry-time preference, dive-number tiebreak), a provider test reproducing the end-to-end symptom with six dives in inverted link order, and a widget test asserting the year renders. --- .../data/repositories/buddy_repository.dart | 19 +++-- .../presentation/pages/buddy_detail_page.dart | 4 +- .../providers/buddy_providers.dart | 21 +++-- .../repositories/buddy_repository_test.dart | 85 +++++++++++++++++++ .../pages/buddy_detail_page_test.dart | 69 +++++++++++++++ .../providers/buddy_providers_test.dart | 65 ++++++++++++++ 6 files changed, 252 insertions(+), 11 deletions(-) diff --git a/lib/features/buddies/data/repositories/buddy_repository.dart b/lib/features/buddies/data/repositories/buddy_repository.dart index c523dfb54c..5511130d78 100644 --- a/lib/features/buddies/data/repositories/buddy_repository.dart +++ b/lib/features/buddies/data/repositories/buddy_repository.dart @@ -829,15 +829,24 @@ class BuddyRepository { return result.data['count'] as int? ?? 0; } - /// Get dives shared with a buddy + /// Get dives shared with a buddy, newest dive first. + /// + /// Ordered by the dive's own timestamp rather than by when the + /// `dive_buddies` link row was written, so callers that truncate the result + /// (the detail page previews the first five) get the newest dives and not an + /// arbitrary slice of the import order. The sort key mirrors + /// `DiveRepository.getAllDives` so the preview agrees with the dive list. + /// The join also drops links whose dive row no longer exists. Future> getDiveIdsForBuddy(String buddyId) async { final results = await _db .customSelect( ''' - SELECT dive_id - FROM dive_buddies - WHERE buddy_id = ? - ORDER BY created_at DESC + SELECT db.dive_id + FROM dive_buddies db + INNER JOIN dives d ON d.id = db.dive_id + WHERE db.buddy_id = ? + ORDER BY COALESCE(d.entry_time, d.dive_date_time) DESC, + d.dive_number DESC ''', variables: [Variable.withString(buddyId)], ) diff --git a/lib/features/buddies/presentation/pages/buddy_detail_page.dart b/lib/features/buddies/presentation/pages/buddy_detail_page.dart index cc8b2e786b..379e30f567 100644 --- a/lib/features/buddies/presentation/pages/buddy_detail_page.dart +++ b/lib/features/buddies/presentation/pages/buddy_detail_page.dart @@ -597,7 +597,9 @@ class _BuddyDetailContent extends ConsumerWidget { final diveIdsAsync = ref.watch(diveIdsForBuddyProvider(buddy.id)); final divesAsync = ref.watch(divesForBuddyProvider(buddy.id)); final theme = Theme.of(context); - final dateFormat = DateFormat.MMMd(); + // Includes the year: shared dives routinely span several years, so a bare + // "Mar 28" is ambiguous (#982). Matches the stats card above. + final dateFormat = DateFormat.yMMMd(); return Card( child: Padding( diff --git a/lib/features/buddies/presentation/providers/buddy_providers.dart b/lib/features/buddies/presentation/providers/buddy_providers.dart index 4d90cee316..7eb7747e70 100644 --- a/lib/features/buddies/presentation/providers/buddy_providers.dart +++ b/lib/features/buddies/presentation/providers/buddy_providers.dart @@ -191,8 +191,16 @@ final diveIdsForBuddyProvider = FutureProvider.family, String>(( return repository.getDiveIdsForBuddy(buddyId); }); +/// How many shared dives the buddy detail page previews before the caller has +/// to tap "view all". +const buddySharedDivePreviewLimit = 5; + /// Full dive data for a buddy provider (for display in buddy detail page) -/// Returns the most recent dives first, limited to a reasonable count for preview +/// +/// Returns the most recent dives first, limited to a reasonable count for +/// preview. [diveIdsForBuddyProvider] already orders by dive date descending, +/// so truncating to the preview limit keeps the newest dives; the Dart sort +/// below only re-asserts that order over the hydrated entities. final divesForBuddyProvider = FutureProvider.family, String>(( ref, buddyId, @@ -200,17 +208,20 @@ final divesForBuddyProvider = FutureProvider.family, String>(( final diveIds = await ref.watch(diveIdsForBuddyProvider(buddyId).future); if (diveIds.isEmpty) return []; - // Fetch full dive data for each ID (limit to first 5 for preview) final dives = []; - for (final diveId in diveIds.take(5)) { + for (final diveId in diveIds.take(buddySharedDivePreviewLimit)) { final dive = await ref.watch(diveProvider(diveId).future); if (dive != null) { dives.add(dive); } } - // Sort by date descending (most recent first) - dives.sort((a, b) => b.dateTime.compareTo(a.dateTime)); + // Most recent first, matching the dive list's sort key. + dives.sort((a, b) { + final byTime = b.effectiveEntryTime.compareTo(a.effectiveEntryTime); + if (byTime != 0) return byTime; + return (b.diveNumber ?? 0).compareTo(a.diveNumber ?? 0); + }); return dives; }); diff --git a/test/features/buddies/data/repositories/buddy_repository_test.dart b/test/features/buddies/data/repositories/buddy_repository_test.dart index 820b733aa3..51d0642b47 100644 --- a/test/features/buddies/data/repositories/buddy_repository_test.dart +++ b/test/features/buddies/data/repositories/buddy_repository_test.dart @@ -426,5 +426,90 @@ void main() { }, ); }); + // Issue #982: the shared-dives preview showed an arbitrary five dives + // because the ids came back in `dive_buddies.created_at` order (when the + // link was written) and the caller truncated before sorting by dive date. + group('getDiveIdsForBuddy ordering (#982)', () { + Future insertDive( + String id, { + required int diveDateTime, + int? entryTime, + int? diveNumber, + }) async { + final db = DatabaseService.instance.database; + await db.customStatement( + 'INSERT INTO dives ' + '(id, dive_date_time, entry_time, dive_number, created_at, updated_at) ' + 'VALUES (?, ?, ?, ?, 1000, 1000)', + [id, diveDateTime, entryTime, diveNumber], + ); + } + + /// Forces the junction row's link timestamp so link order can be made to + /// contradict dive order. + Future setLinkCreatedAt(String diveId, int createdAt) async { + final db = DatabaseService.instance.database; + await db.customStatement( + 'UPDATE dive_buddies SET created_at = ? WHERE dive_id = ?', + [createdAt, diveId], + ); + } + + test( + 'returns newest dive first regardless of link creation order', + () async { + final buddy = await repository.createBuddy(createTestBuddy(id: 'b1')); + await insertDive('old', diveDateTime: 1000); + await insertDive('newest', diveDateTime: 3000); + await insertDive('middle', diveDateTime: 2000); + for (final id in ['old', 'newest', 'middle']) { + await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); + } + // Link order deliberately inverted relative to dive date order. + await setLinkCreatedAt('old', 9000); + await setLinkCreatedAt('newest', 8000); + await setLinkCreatedAt('middle', 7000); + + final diveIds = await repository.getDiveIdsForBuddy(buddy.id); + + expect(diveIds, equals(['newest', 'middle', 'old'])); + }, + ); + + test('prefers entry time over dive date time', () async { + final buddy = await repository.createBuddy(createTestBuddy(id: 'b1')); + // `later` has the older dive_date_time but the newer entry_time. + await insertDive('earlier', diveDateTime: 5000); + await insertDive('later', diveDateTime: 4000, entryTime: 6000); + for (final id in ['earlier', 'later']) { + await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); + } + // Link order deliberately inverted relative to entry time order. + await setLinkCreatedAt('earlier', 9000); + await setLinkCreatedAt('later', 1); + + final diveIds = await repository.getDiveIdsForBuddy(buddy.id); + + expect(diveIds, equals(['later', 'earlier'])); + }); + + test( + 'breaks ties on the same timestamp by dive number descending', + () async { + final buddy = await repository.createBuddy(createTestBuddy(id: 'b1')); + await insertDive('lower', diveDateTime: 1000, diveNumber: 893); + await insertDive('higher', diveDateTime: 1000, diveNumber: 894); + for (final id in ['lower', 'higher']) { + await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); + } + await setLinkCreatedAt('lower', 9000); + await setLinkCreatedAt('higher', 1); + + final diveIds = await repository.getDiveIdsForBuddy(buddy.id); + + expect(diveIds, equals(['higher', 'lower'])); + }, + ); + }); }); } diff --git a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart index fdd72078ca..990c231b28 100644 --- a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart +++ b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; @@ -193,4 +194,72 @@ void main() { expect(find.text('45min'), findsOneWidget); }); }); + + // Issue #982: the shared-dives list formatted dates with DateFormat.MMMd(), + // so a list spanning several years rendered ambiguous labels like "Mar 28". + group('BuddyDetailPage shared dive dates (#982)', () { + testWidgets('renders the year alongside the dive date', (tester) async { + final previousLocale = Intl.defaultLocale; + Intl.defaultLocale = 'en'; + addTearDown(() => Intl.defaultLocale = previousLocale); + + final buddy = Buddy( + id: 'buddy-1', + name: 'Jane Doe', + notes: '', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + // createTestDiveWithBottomTime dives are dated 2026-03-28. + final dives = [ + createTestDiveWithBottomTime(id: 'buddy-dive-1', diveNumber: 1), + ]; + + final overrides = await getBaseOverrides(); + + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(390, 844); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + buddyByIdProvider(buddy.id).overrideWith((ref) async => buddy), + buddyStatsProvider( + buddy.id, + ).overrideWith((ref) async => const BuddyStats(totalDives: 1)), + diveIdsForBuddyProvider( + buddy.id, + ).overrideWith((ref) async => ['buddy-dive-1']), + divesForBuddyProvider(buddy.id).overrideWith((ref) async => dives), + ].cast(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BuddyDetailPage(buddyId: buddy.id, embedded: true), + ), + ), + ); + // Tolerate overflow errors in test layout + final errors = []; + FlutterError.onError = (d) => errors.add(d); + await tester.pumpAndSettle(); + FlutterError.onError = FlutterError.presentError; + + expect( + find.text(DateFormat.yMMMd().format(dives.first.dateTime)), + findsOneWidget, + ); + expect( + find.text(DateFormat.MMMd().format(dives.first.dateTime)), + findsNothing, + ); + }); + }); } diff --git a/test/features/buddies/presentation/providers/buddy_providers_test.dart b/test/features/buddies/presentation/providers/buddy_providers_test.dart index 37a3bbe5bd..4b4153588e 100644 --- a/test/features/buddies/presentation/providers/buddy_providers_test.dart +++ b/test/features/buddies/presentation/providers/buddy_providers_test.dart @@ -8,6 +8,7 @@ import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/dive_roles/domain/entities/dive_role.dart'; import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; @@ -47,6 +48,25 @@ Future _insertDive(db.AppDatabase database, {required String id}) async { ); } +/// Like [_insertDive] but with a caller-chosen dive date, for ordering tests. +Future _insertDiveAt( + db.AppDatabase database, { + required String id, + required DateTime diveDateTime, +}) async { + final now = DateTime.now().millisecondsSinceEpoch; + await database + .into(database.dives) + .insert( + db.DivesCompanion( + id: Value(id), + diveDateTime: Value(diveDateTime.millisecondsSinceEpoch), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); +} + void main() { late SharedPreferences prefs; late BuddyRepository buddyRepo; @@ -220,4 +240,49 @@ void main() { ); }); }); + + // Issue #982: the buddy detail page's shared-dives preview showed an + // arbitrary five dives because the ids arrived in `dive_buddies.created_at` + // order (when the link was written) and only the surviving five were sorted + // by dive date. A dive from a previous year outranked the newest one. + group('divesForBuddyProvider ordering (#982)', () { + test('previews the five newest dives, newest first', () async { + final diver = await seedCurrentDiver(); + final buddy = await buddyRepo.createBuddy( + _makeBuddy(name: 'Dive Partner', diverId: diver.id), + ); + + // Six dives. The link timestamps are written in the exact reverse of the + // dive order, so an implementation that truncates before sorting keeps + // the five OLDEST dives. + final diveIds = ['d1', 'd2', 'd3', 'd4', 'd5', 'd6']; + for (var i = 0; i < diveIds.length; i++) { + await _insertDiveAt( + database, + id: diveIds[i], + diveDateTime: DateTime(2020 + i, 6, 1), + ); + await buddyRepo.addBuddyToDive(diveIds[i], buddy.id, DiveRole.buddyId); + await database.customStatement( + 'UPDATE dive_buddies SET created_at = ? WHERE dive_id = ?', + [diveIds.length - i, diveIds[i]], + ); + } + + final container = makeContainer(); + addTearDown(container.dispose); + + final dives = await container.read( + divesForBuddyProvider(buddy.id).future, + ); + + expect( + dives.map((d) => d.id).toList(), + equals(['d6', 'd5', 'd4', 'd3', 'd2']), + reason: + 'the preview must take the five newest dives, not the first five ' + 'buddy links', + ); + }); + }); } From 6203872dec932c5c1c7e94c2c5e4685c10ed7043 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:56:34 -0400 Subject: [PATCH 048/122] fix(statistics): widen the records empty-state gate and pin the test locale Review feedback on #1291. The empty-state gate checked only deepest/longest/coldest/warmest, but the page also renders shallowestDive and the first/last milestone cards. The first/last queries carry no field predicate, so dives logged with only a date populate the milestones while every superlative stays null: the page then claimed "no records" and hid milestone cards that had content. The gate now covers every slot the page can render. Also pin locale: Locale('en') on the RecordsPage widget tests, which assert English labels and previously relied on the runner's platform locale. --- .../presentation/pages/records_page.dart | 18 +++-- .../presentation/pages/records_page_test.dart | 74 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/lib/features/statistics/presentation/pages/records_page.dart b/lib/features/statistics/presentation/pages/records_page.dart index 5c6bd36388..cc456033ed 100644 --- a/lib/features/statistics/presentation/pages/records_page.dart +++ b/lib/features/statistics/presentation/pages/records_page.dart @@ -74,11 +74,19 @@ class RecordsPage extends ConsumerWidget { final settings = ref.watch(settingsProvider); final units = UnitFormatter(settings); - final hasRecords = - records.deepestDive != null || - records.longestDive != null || - records.coldestDive != null || - records.warmestDive != null; + // Every slot the page can render, not just the four superlative cards: + // firstDive/lastDive carry no field predicate, so dives logged with only a + // date populate the milestones while all four superlatives stay null. + // Gating on the four alone hid milestone cards that had content. + final hasRecords = [ + records.deepestDive, + records.longestDive, + records.coldestDive, + records.warmestDive, + records.shallowestDive, + records.firstDive, + records.lastDive, + ].any((record) => record != null); if (!hasRecords) { // "Start logging dives" is wrong advice when the logbook is full and the diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index 30a126edda..59f627d8c9 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -538,6 +538,7 @@ void main() { ProviderScope( overrides: getOverrides(), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -555,6 +556,7 @@ void main() { ProviderScope( overrides: getOverrides(), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -581,6 +583,7 @@ void main() { filter: const DiveFilterState(favoritesOnly: true), ), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -600,6 +603,7 @@ void main() { ProviderScope( overrides: getOverrides(), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -621,6 +625,7 @@ void main() { filter: const DiveFilterState(favoritesOnly: true), ), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -637,6 +642,7 @@ void main() { ProviderScope( overrides: getOverrides(), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -672,6 +678,7 @@ void main() { ProviderScope( overrides: getOverrides(diveRecordsOverride: (ref) async => records), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -685,6 +692,72 @@ void main() { expect(find.text('Longest Dive'), findsOneWidget); }); + // firstDive/lastDive carry no field predicate, so a dive logged with only + // a date populates the milestones while every superlative stays null. The + // page must not call that "no records". + testWidgets('shows milestones when only first and last dive are known', ( + tester, + ) async { + final records = DiveRecords( + firstDive: DiveRecord( + diveId: '1', + diveNumber: 1, + dateTime: DateTime(2024, 6, 15), + ), + lastDive: DiveRecord( + diveId: '2', + diveNumber: 2, + dateTime: DateTime(2024, 7, 20), + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: getOverrides(diveRecordsOverride: (ref) async => records), + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No Records Yet'), findsNothing); + expect(find.text('First Dive'), findsOneWidget); + expect(find.text('Most Recent Dive'), findsOneWidget); + }); + + testWidgets('shows the shallowest dive card when it is the only record', ( + tester, + ) async { + final records = DiveRecords( + shallowestDive: DiveRecord( + diveId: '1', + diveNumber: 1, + dateTime: DateTime(2024, 6, 15), + maxDepth: 6.0, + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: getOverrides(diveRecordsOverride: (ref) async => records), + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No Records Yet'), findsNothing); + expect(find.text('Shallowest Dive'), findsOneWidget); + }); + testWidgets('should display error state with retry button', (tester) async { await tester.pumpWidget( ProviderScope( @@ -694,6 +767,7 @@ void main() { }, ), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), From 0f523dcea345097923fa334980687d548345649c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:58:50 -0400 Subject: [PATCH 049/122] refactor(location): return PlaceLookup and take the geocode language per call (#1187) --- .../helpers/uddf_screenshot_helper.dart | 6 +- lib/core/services/geocoding/place_lookup.dart | 44 +++++ lib/core/services/location_service.dart | 185 ++++++++++-------- .../data/services/uddf_entity_importer.dart | 2 + .../presentation/pages/site_edit_page.dart | 7 +- .../widgets/location_picker_map.dart | 2 + .../widgets/region_download_dialog.dart | 1 + test/core/services/location_service_test.dart | 100 ++++++++-- .../pickers/site_picker_sheet_test.dart | 1 + .../site_edit_altitude_autofill_test.dart | 8 +- .../pages/site_edit_page_test.dart | 9 +- .../pages/site_edit_seed_location_test.dart | 10 +- test/integration/uddf_test_importer.dart | 6 +- 13 files changed, 268 insertions(+), 113 deletions(-) create mode 100644 lib/core/services/geocoding/place_lookup.dart diff --git a/integration_test/helpers/uddf_screenshot_helper.dart b/integration_test/helpers/uddf_screenshot_helper.dart index 9d7ac51a9a..fcae3bab42 100644 --- a/integration_test/helpers/uddf_screenshot_helper.dart +++ b/integration_test/helpers/uddf_screenshot_helper.dart @@ -439,7 +439,11 @@ class UddfScreenshotImporter { // If coordinates exist but country/region are missing, use reverse geolocation if (lat != null && lon != null && (country == null || country.isEmpty)) { - final geoResult = await locationService.reverseGeocode(lat, lon); + final geoResult = await locationService.reverseGeocode( + lat, + lon, + languageCode: LocationService.defaultLanguageCode, + ); country = geoResult.country; region = geoResult.region; } diff --git a/lib/core/services/geocoding/place_lookup.dart b/lib/core/services/geocoding/place_lookup.dart new file mode 100644 index 0000000000..01f0aeb328 --- /dev/null +++ b/lib/core/services/geocoding/place_lookup.dart @@ -0,0 +1,44 @@ +/// What a reverse geocode of one coordinate found. +/// +/// Every field is optional: a point in open sea has no locality, a point on +/// land has no body of water. [networkFailed] is true when the lookup could +/// not reach the geocoder at all, so a caller iterating many sites can stop +/// early instead of collecting one failure per site. +class PlaceLookup { + const PlaceLookup({ + this.country, + this.region, + this.locality, + this.bodyOfWater, + this.networkFailed = false, + }); + + const PlaceLookup.empty() : this(); + + const PlaceLookup.unavailable() : this(networkFailed: true); + + final String? country; + final String? region; + final String? locality; + final String? bodyOfWater; + final bool networkFailed; + + bool get isEmpty => + country == null && + region == null && + locality == null && + bodyOfWater == null; + + PlaceLookup copyWith({String? bodyOfWater}) => PlaceLookup( + country: country, + region: region, + locality: locality, + bodyOfWater: bodyOfWater ?? this.bodyOfWater, + networkFailed: networkFailed, + ); + + @override + String toString() => + 'PlaceLookup(country: $country, region: $region, locality: $locality, ' + 'bodyOfWater: $bodyOfWater, networkFailed: $networkFailed)'; +} diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index 41f48a784e..409bba959c 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -1,11 +1,12 @@ import 'dart:convert'; -import 'dart:io' show Platform, HttpClient; +import 'dart:io' show Platform, HttpClient, SocketException; import 'dart:ui' show Locale; import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; import 'package:geolocator/geolocator.dart'; import 'package:geocoding/geocoding.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/logger_service.dart'; /// Check if we're on a mobile platform (iOS/Android) @@ -19,6 +20,7 @@ class LocationResult { final String? country; final String? region; final String? locality; + final String? bodyOfWater; const LocationResult({ required this.latitude, @@ -27,8 +29,17 @@ class LocationResult { this.country, this.region, this.locality, + this.bodyOfWater, }); + /// The geocoded part of this result, in the shape the site form consumes. + PlaceLookup get place => PlaceLookup( + country: country, + region: region, + locality: locality, + bodyOfWater: bodyOfWater, + ); + @override String toString() => 'LocationResult(lat: $latitude, lng: $longitude, country: $country, region: $region)'; @@ -36,32 +47,31 @@ class LocationResult { /// Service for handling device GPS location and geocoding class LocationService { - /// Nominatim reverse-geocode URI. accept-language pins results to English - /// so country/region strings group consistently in statistics (#214). - static Uri buildReverseGeocodeUri(double latitude, double longitude) => - Uri.parse( - 'https://nominatim.openstreetmap.org/reverse?format=json' - '&lat=$latitude&lon=$longitude&zoom=10&accept-language=en', - ); + /// The language every existing row was geocoded in. Issue #214 pinned + /// results to English because the platform geocoder answered in the device + /// locale and split one country across 'Spanien' and 'España'. The pin is + /// now a synced per-diver setting (issue #1187) whose default is this + /// value, so unchanged users keep grouping exactly as before. + static const String defaultLanguageCode = 'en'; + + /// Nominatim reverse-geocode URI for the address layer. + static Uri buildReverseGeocodeUri( + double latitude, + double longitude, { + required String languageCode, + }) => Uri.parse( + 'https://nominatim.openstreetmap.org/reverse?format=json' + '&lat=$latitude&lon=$longitude&zoom=10&accept-language=$languageCode', + ); - /// Nominatim forward-geocode URI, English-pinned like the reverse path. + /// Nominatim forward-geocode URI, English-pinned: dive centres are matched + /// by address text, not grouped in statistics. static Uri buildForwardGeocodeUri(String address) => Uri.parse( 'https://nominatim.openstreetmap.org/search?format=json' '&q=${Uri.encodeComponent(address)}&limit=1&addressdetails=1' - '&accept-language=en', + '&accept-language=$defaultLanguageCode', ); - /// The platform geocoder answers in the DEVICE locale unless a locale is - /// supplied, which stored 'Spanien' on German phones and 'España' on - /// Spanish ones for the same country (#214). - /// - /// geocoding 5 takes the locale per call, which retires the old - /// pin-once-per-process memo. Do NOT pin via `Geocoding(locale: ...)` - /// instead: that constructor parameter is dropped upstream (the redirecting - /// constructor never forwards it to the field), so it would silently - /// reintroduce #214. - static const Locale _geocoderLocale = Locale('en'); - /// Routes reverse geocoding through the platform geocoder. /// /// True on mobile in production. `Platform.isIOS`/`isAndroid` are @@ -103,6 +113,7 @@ class LocationService { Future getCurrentLocation({ bool includeGeocoding = true, Duration timeout = const Duration(seconds: 15), + String languageCode = defaultLanguageCode, }) async { try { // Check if location services are enabled @@ -188,28 +199,25 @@ class LocationService { 'Got position: ${position.latitude}, ${position.longitude} (accuracy: ${position.accuracy}m)', ); - String? country; - String? region; - String? locality; + PlaceLookup place = const PlaceLookup.empty(); // Perform reverse geocoding if requested if (includeGeocoding) { - final geocodeResult = await reverseGeocode( + place = await reverseGeocode( position.latitude, position.longitude, + languageCode: languageCode, ); - country = geocodeResult.country; - region = geocodeResult.region; - locality = geocodeResult.locality; } return LocationResult( latitude: position.latitude, longitude: position.longitude, accuracy: position.accuracy, - country: country, - region: region, - locality: locality, + country: place.country, + region: place.region, + locality: place.locality, + bodyOfWater: place.bodyOfWater, ); } catch (e, stackTrace) { _log.error( @@ -221,14 +229,19 @@ class LocationService { } } - /// Reverse geocode a location to get country/region - /// Uses native geocoding on mobile, falls back to OpenStreetMap Nominatim on desktop - Future<({String? country, String? region, String? locality})> reverseGeocode( + /// Reverse geocode a coordinate into country, region and locality, in the + /// language named by [languageCode] (an ISO 639-1 code such as 'en'). + /// + /// Uses the platform geocoder on mobile and falls back to OpenStreetMap + /// Nominatim everywhere else. Never throws: a geocoder that cannot be + /// reached yields [PlaceLookup.unavailable]. + Future reverseGeocode( double latitude, - double longitude, - ) async { + double longitude, { + required String languageCode, + }) async { try { - _log.info('Reverse geocoding: $latitude, $longitude'); + _log.info('Reverse geocoding: $latitude, $longitude ($languageCode)'); // Try native geocoding first (works on iOS/Android) if (_useNativeGeocoder) { @@ -239,14 +252,14 @@ class LocationService { final placemarks = await Geocoding().placemarkFromCoordinates( latitude, longitude, - locale: _geocoderLocale, + locale: Locale(languageCode), ); if (placemarks.isNotEmpty) { final place = placemarks.first; _log.info( 'Native geocoded: ${place.locality}, ${place.administrativeArea}, ${place.country}', ); - return ( + return PlaceLookup( country: place.country, region: place.administrativeArea, locality: place.locality, @@ -258,57 +271,67 @@ class LocationService { } // Fallback to OpenStreetMap Nominatim API (works on all platforms) - return await _reverseGeocodeWeb(latitude, longitude); + return await _reverseGeocodeWeb(latitude, longitude, languageCode); } catch (e, stackTrace) { _log.error('Reverse geocoding failed', error: e, stackTrace: stackTrace); - return (country: null, region: null, locality: null); + return const PlaceLookup.unavailable(); } } /// Web-based reverse geocoding using OpenStreetMap Nominatim - Future<({String? country, String? region, String? locality})> - _reverseGeocodeWeb(double latitude, double longitude) async { + Future _reverseGeocodeWeb( + double latitude, + double longitude, + String languageCode, + ) async { try { - final url = buildReverseGeocodeUri(latitude, longitude); - - final client = HttpClient(); - client.userAgent = 'Submersion Dive Log App'; - - // Close in a finally so the client's sockets are released even when - // the response body or JSON decode throws. - try { - final request = await client.getUrl(url); - request.headers.set('Accept-Language', 'en'); - final response = await request.close(); - - if (response.statusCode == 200) { - final body = await response.transform(utf8.decoder).join(); - final json = jsonDecode(body) as Map; - final address = json['address'] as Map?; - - if (address != null) { - final country = address['country'] as String?; - final region = - address['state'] as String? ?? - address['province'] as String? ?? - address['region'] as String?; - final locality = - address['city'] as String? ?? - address['town'] as String? ?? - address['village'] as String?; - - _log.info('Web geocoded: $locality, $region, $country'); - return (country: country, region: region, locality: locality); - } - } - - return (country: null, region: null, locality: null); - } finally { - client.close(); - } + final json = await _fetchNominatimJson( + buildReverseGeocodeUri(latitude, longitude, languageCode: languageCode), + languageCode, + ); + final address = json?['address'] as Map?; + if (address == null) return const PlaceLookup.empty(); + + final country = address['country'] as String?; + final region = + address['state'] as String? ?? + address['province'] as String? ?? + address['region'] as String?; + final locality = + address['city'] as String? ?? + address['town'] as String? ?? + address['village'] as String?; + + _log.info('Web geocoded: $locality, $region, $country'); + return PlaceLookup(country: country, region: region, locality: locality); + } on SocketException catch (e) { + _log.warning('Web reverse geocoding unreachable: $e'); + return const PlaceLookup.unavailable(); } catch (e) { _log.warning('Web reverse geocoding failed: $e'); - return (country: null, region: null, locality: null); + return const PlaceLookup.empty(); + } + } + + /// One Nominatim GET. Returns the decoded object, or null for a non-200 + /// status. Lets socket errors propagate so callers can tell "offline" from + /// "nothing there". The client is closed in a finally so its sockets are + /// released even when the body or the JSON decode throws. + Future?> _fetchNominatimJson( + Uri url, + String languageCode, + ) async { + final client = HttpClient(); + client.userAgent = 'Submersion Dive Log App'; + try { + final request = await client.getUrl(url); + request.headers.set('Accept-Language', languageCode); + final response = await request.close(); + if (response.statusCode != 200) return null; + final body = await response.transform(utf8.decoder).join(); + return jsonDecode(body) as Map; + } finally { + client.close(); } } diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index 73b58d1bb7..f9452b9e69 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -1035,6 +1035,7 @@ class UddfEntityImporter { final geocodeResult = await LocationService.instance.reverseGeocode( lat, lon, + languageCode: LocationService.defaultLanguageCode, ); country ??= geocodeResult.country; region ??= geocodeResult.region; @@ -1108,6 +1109,7 @@ class UddfEntityImporter { final geocodeResult = await LocationService.instance.reverseGeocode( lat, lon, + languageCode: LocationService.defaultLanguageCode, ); country ??= geocodeResult.country; region ??= geocodeResult.region; diff --git a/lib/features/dive_sites/presentation/pages/site_edit_page.dart b/lib/features/dive_sites/presentation/pages/site_edit_page.dart index 155751f26e..5792c0386e 100644 --- a/lib/features/dive_sites/presentation/pages/site_edit_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_edit_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/services/location_service.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; @@ -211,7 +212,11 @@ class _SiteEditPageState extends ConsumerState { if (!mounted) return; final result = await ref .read(locationServiceProvider) - .reverseGeocode(loc.latitude, loc.longitude); + .reverseGeocode( + loc.latitude, + loc.longitude, + languageCode: LocationService.defaultLanguageCode, + ); if (!mounted) return; setState(() { _isApplyingInitialValues = true; diff --git a/lib/features/dive_sites/presentation/widgets/location_picker_map.dart b/lib/features/dive_sites/presentation/widgets/location_picker_map.dart index 7324fdc6e4..5d2b7182b4 100644 --- a/lib/features/dive_sites/presentation/widgets/location_picker_map.dart +++ b/lib/features/dive_sites/presentation/widgets/location_picker_map.dart @@ -64,6 +64,7 @@ class _LocationPickerMapState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( _selectedLocation!.latitude, _selectedLocation!.longitude, + languageCode: LocationService.defaultLanguageCode, ); if (mounted) { @@ -99,6 +100,7 @@ class _LocationPickerMapState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( _selectedLocation!.latitude, _selectedLocation!.longitude, + languageCode: LocationService.defaultLanguageCode, ); if (mounted) { diff --git a/lib/features/maps/presentation/widgets/region_download_dialog.dart b/lib/features/maps/presentation/widgets/region_download_dialog.dart index 0fb2a661f9..33868577f0 100644 --- a/lib/features/maps/presentation/widgets/region_download_dialog.dart +++ b/lib/features/maps/presentation/widgets/region_download_dialog.dart @@ -71,6 +71,7 @@ class _RegionDownloadDialogState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( centerLat, centerLng, + languageCode: LocationService.defaultLanguageCode, ); if (mounted && _nameController.text.isEmpty) { diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index 71da05e3f0..975596b006 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -59,6 +59,22 @@ class _FakeHttpClient implements HttpClient { dynamic noSuchMethod(Invocation invocation) => null; } +class _ThrowingHttpClient implements HttpClient { + @override + String? userAgent; + + @override + Future getUrl(Uri url) async { + throw const SocketException('offline'); + } + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + class _FakeHttpClientRequest implements HttpClientRequest { _FakeHttpClientRequest(this.uri, this._server); @@ -124,11 +140,15 @@ void main() { final service = LocationService.instance; group('Nominatim URIs pin English results (#214)', () { - test('reverse geocode URI carries accept-language=en', () { - final uri = LocationService.buildReverseGeocodeUri(36.0, -5.6); + test('reverse geocode URI carries the requested accept-language', () { + final uri = LocationService.buildReverseGeocodeUri( + 36.0, + -5.6, + languageCode: 'fr', + ); expect( uri.queryParameters['accept-language'], - 'en', + 'fr', reason: 'without a pinned language Nominatim answers in the request ' 'locale, splitting statistics into Spain/Spanien/España rows', @@ -159,7 +179,7 @@ void main() { ); final result = await server.run( - () => service.reverseGeocode(36.0143, -5.6044), + () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), ); expect(result.country, 'Spain'); @@ -177,7 +197,9 @@ void main() { }), ); - await server.run(() => service.reverseGeocode(36.0143, -5.6044)); + await server.run( + () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), + ); expect(server.requestedUris, hasLength(1)); expect( @@ -200,6 +222,32 @@ void main() { }, ); + test('sends the requested language in the URI and the headers', () async { + final server = _FakeNominatim( + body: jsonEncode({ + 'address': {'country': 'Schweiz'}, + }), + ); + + final result = await server.run( + () => service.reverseGeocode(47.0276, 8.4006, languageCode: 'de'), + ); + + expect(result.country, 'Schweiz'); + expect(server.lastUri.queryParameters['accept-language'], 'de'); + expect(server.lastHeaders['accept-language'], 'de'); + }); + + test('returns PlaceLookup.unavailable when the request throws', () async { + final result = await HttpOverrides.runZoned( + () => service.reverseGeocode(47.0, 8.4, languageCode: 'en'), + createHttpClient: (_) => _ThrowingHttpClient(), + ); + + expect(result.isEmpty, isTrue); + expect(result.networkFailed, isTrue); + }); + test('falls back from state to province for the region', () async { final server = _FakeNominatim( body: jsonEncode({ @@ -212,7 +260,7 @@ void main() { ); final result = await server.run( - () => service.reverseGeocode(45.2542, -81.6653), + () => service.reverseGeocode(45.2542, -81.6653, languageCode: 'en'), ); expect(result.region, 'Ontario'); @@ -231,7 +279,7 @@ void main() { ); final result = await server.run( - () => service.reverseGeocode(28.5091, 34.5136), + () => service.reverseGeocode(28.5091, 34.5136, languageCode: 'en'), ); expect(result.country, 'Egypt'); @@ -246,7 +294,9 @@ void main() { body: jsonEncode({'error': 'Unable to geocode'}), ); - final result = await server.run(() => service.reverseGeocode(0.0, 0.0)); + final result = await server.run( + () => service.reverseGeocode(0.0, 0.0, languageCode: 'en'), + ); expect(result.country, isNull); expect(result.region, isNull); @@ -261,7 +311,7 @@ void main() { ); final result = await server.run( - () => service.reverseGeocode(36.0143, -5.6044), + () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), ); expect(result.country, isNull); @@ -273,7 +323,7 @@ void main() { final server = _FakeNominatim(body: 'rate limited'); final result = await server.run( - () => service.reverseGeocode(36.0143, -5.6044), + () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), ); expect(result.country, isNull); @@ -284,7 +334,9 @@ void main() { test('closes the HttpClient even when the body fails to parse', () async { final server = _FakeNominatim(body: 'not json'); - await server.run(() => service.reverseGeocode(36.0143, -5.6044)); + await server.run( + () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), + ); expect( server.clientCloseCount, @@ -471,7 +523,7 @@ void main() { LocationService.debugForceNativeGeocoder = false; }); - test('asks the geocoder for English results', () async { + test('asks the geocoder for the requested language', () async { final geocoding = _FakeGeocoding( placemarks: const [ Placemark( @@ -483,9 +535,13 @@ void main() { ); GeocodingPlatformFactory.instance = _FakeGeocodingFactory(geocoding); - final result = await service.reverseGeocode(36.0143, -5.6044); + final result = await service.reverseGeocode( + 36.0143, + -5.6044, + languageCode: 'es', + ); - expect(geocoding.locales, [const Locale('en')]); + expect(geocoding.locales, [const Locale('es')]); expect(result.country, 'Spain'); expect(result.region, 'Andalusia'); expect(result.locality, 'Tarifa'); @@ -498,9 +554,9 @@ void main() { GeocodingPlatformFactory.instance = _FakeGeocodingFactory(geocoding); await Future.wait([ - service.reverseGeocode(36.0, -5.6), - service.reverseGeocode(37.0, -5.7), - service.reverseGeocode(38.0, -5.8), + service.reverseGeocode(36.0, -5.6, languageCode: 'en'), + service.reverseGeocode(37.0, -5.7, languageCode: 'en'), + service.reverseGeocode(38.0, -5.8, languageCode: 'en'), ]); expect( @@ -524,14 +580,20 @@ void main() { final server = _FakeNominatim( body: '{"address": {"country": "Fallback"}}', ); - final first = await server.run(() => service.reverseGeocode(36.0, -5.6)); + final first = await server.run( + () => service.reverseGeocode(36.0, -5.6, languageCode: 'en'), + ); expect( first.country, 'Fallback', reason: 'a native geocoder failure is non-fatal', ); - final second = await service.reverseGeocode(36.0, -5.6); + final second = await service.reverseGeocode( + 36.0, + -5.6, + languageCode: 'en', + ); expect( second.country, diff --git a/test/features/dive_log/presentation/widgets/pickers/site_picker_sheet_test.dart b/test/features/dive_log/presentation/widgets/pickers/site_picker_sheet_test.dart index 42c69bc645..88bafaa9be 100644 --- a/test/features/dive_log/presentation/widgets/pickers/site_picker_sheet_test.dart +++ b/test/features/dive_log/presentation/widgets/pickers/site_picker_sheet_test.dart @@ -56,6 +56,7 @@ class _FakeLocationService implements LocationService { Future getCurrentLocation({ bool includeGeocoding = true, Duration timeout = const Duration(seconds: 15), + String languageCode = LocationService.defaultLanguageCode, }) { calls++; return pending?.future ?? Future.value(result); diff --git a/test/features/dive_sites/presentation/pages/site_edit_altitude_autofill_test.dart b/test/features/dive_sites/presentation/pages/site_edit_altitude_autofill_test.dart index e61e6192e9..fd2f8c37b9 100644 --- a/test/features/dive_sites/presentation/pages/site_edit_altitude_autofill_test.dart +++ b/test/features/dive_sites/presentation/pages/site_edit_altitude_autofill_test.dart @@ -7,6 +7,7 @@ import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/providers/location_service_provider.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/location_service.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; @@ -21,10 +22,11 @@ import '../../../../helpers/test_database.dart'; /// Stub geocoder: the altitude path must not depend on reverse geocoding. class _StubLocationService implements LocationService { @override - Future<({String? country, String? region, String? locality})> reverseGeocode( + Future reverseGeocode( double latitude, - double longitude, - ) async => (country: null, region: null, locality: null); + double longitude, { + required String languageCode, + }) async => const PlaceLookup.empty(); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/features/dive_sites/presentation/pages/site_edit_page_test.dart b/test/features/dive_sites/presentation/pages/site_edit_page_test.dart index cc2eaf922d..ca471684f3 100644 --- a/test/features/dive_sites/presentation/pages/site_edit_page_test.dart +++ b/test/features/dive_sites/presentation/pages/site_edit_page_test.dart @@ -12,6 +12,7 @@ import 'package:submersion/features/dive_sites/presentation/providers/site_provi import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/location_service.dart'; import 'package:submersion/shared/widgets/forms/suggestion_form_row.dart'; @@ -40,15 +41,17 @@ class _FakeLocationService implements LocationService { final String? region; @override - Future<({String? country, String? region, String? locality})> reverseGeocode( + Future reverseGeocode( double latitude, - double longitude, - ) async => (country: country, region: region, locality: null); + double longitude, { + required String languageCode, + }) async => PlaceLookup(country: country, region: region); @override Future getCurrentLocation({ bool includeGeocoding = true, Duration timeout = const Duration(seconds: 15), + String languageCode = LocationService.defaultLanguageCode, }) async => LocationResult( latitude: 12.3, longitude: 45.6, diff --git a/test/features/dive_sites/presentation/pages/site_edit_seed_location_test.dart b/test/features/dive_sites/presentation/pages/site_edit_seed_location_test.dart index 804e96831c..cf0ae853fd 100644 --- a/test/features/dive_sites/presentation/pages/site_edit_seed_location_test.dart +++ b/test/features/dive_sites/presentation/pages/site_edit_seed_location_test.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/providers/location_service_provider.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/location_service.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; @@ -22,12 +23,13 @@ class _RecordingLocationService implements LocationService { ({double lat, double lng})? geocodedWith; @override - Future<({String? country, String? region, String? locality})> reverseGeocode( + Future reverseGeocode( double latitude, - double longitude, - ) async { + double longitude, { + required String languageCode, + }) async { geocodedWith = (lat: latitude, lng: longitude); - return (country: 'Testland', region: 'Test Region', locality: null); + return const PlaceLookup(country: 'Testland', region: 'Test Region'); } @override diff --git a/test/integration/uddf_test_importer.dart b/test/integration/uddf_test_importer.dart index ef4e505e99..ccfb69ceb6 100644 --- a/test/integration/uddf_test_importer.dart +++ b/test/integration/uddf_test_importer.dart @@ -439,7 +439,11 @@ class UddfTestImporter { // If coordinates exist but country/region are missing, use reverse geolocation if (lat != null && lon != null && (country == null || country.isEmpty)) { - final geoResult = await locationService.reverseGeocode(lat, lon); + final geoResult = await locationService.reverseGeocode( + lat, + lon, + languageCode: LocationService.defaultLanguageCode, + ); country = geoResult.country; region = geoResult.region; } From f4939397598f3936aeff0762b7e8104c40eccd33 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:01:06 -0400 Subject: [PATCH 050/122] feat(location): read the body of water from the Nominatim natural layer (#1187) --- lib/core/services/location_service.dart | 82 +++++++++- test/core/services/location_service_test.dart | 152 +++++++++++++++++- 2 files changed, 223 insertions(+), 11 deletions(-) diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index 409bba959c..a821cf0422 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -64,6 +64,36 @@ class LocationService { '&lat=$latitude&lon=$longitude&zoom=10&accept-language=$languageCode', ); + /// Nominatim reverse-geocode URI for the natural layer, which answers with + /// the lake, bay or strait a point lies in. zoom=14 keeps the answer to a + /// named feature rather than the whole region. Nominatim has no ocean + /// polygons, so open-sea points come back "Unable to geocode". + static Uri buildNaturalFeatureUri( + double latitude, + double longitude, { + required String languageCode, + }) => Uri.parse( + 'https://nominatim.openstreetmap.org/reverse?format=json' + '&lat=$latitude&lon=$longitude&zoom=14&layer=natural' + '&accept-language=$languageCode', + ); + + /// The name of a water feature from a natural-layer answer, or null when + /// the hit is not water. The natural layer also carries mountain ranges, + /// saddles and peaks; class `water` covers lakes, reservoirs and rivers, + /// and bays and straits arrive as class `natural`. + static String? bodyOfWaterFromNaturalFeature(Map json) { + final osmClass = json['class'] as String?; + final type = json['type'] as String?; + final name = (json['name'] as String?)?.trim(); + if (name == null || name.isEmpty) return null; + if (osmClass == 'water') return name; + if (osmClass == 'natural' && (type == 'bay' || type == 'strait')) { + return name; + } + return null; + } + /// Nominatim forward-geocode URI, English-pinned: dive centres are matched /// by address text, not grouped in statistics. static Uri buildForwardGeocodeUri(String address) => Uri.parse( @@ -259,10 +289,15 @@ class LocationService { _log.info( 'Native geocoded: ${place.locality}, ${place.administrativeArea}, ${place.country}', ); - return PlaceLookup( - country: place.country, - region: place.administrativeArea, - locality: place.locality, + return await _withBodyOfWater( + PlaceLookup( + country: place.country, + region: place.administrativeArea, + locality: place.locality, + ), + latitude, + longitude, + languageCode, ); } } catch (e) { @@ -271,7 +306,13 @@ class LocationService { } // Fallback to OpenStreetMap Nominatim API (works on all platforms) - return await _reverseGeocodeWeb(latitude, longitude, languageCode); + final address = await _reverseGeocodeWeb( + latitude, + longitude, + languageCode, + ); + if (address.networkFailed) return address; + return await _withBodyOfWater(address, latitude, longitude, languageCode); } catch (e, stackTrace) { _log.error('Reverse geocoding failed', error: e, stackTrace: stackTrace); return const PlaceLookup.unavailable(); @@ -313,6 +354,37 @@ class LocationService { } } + Future _withBodyOfWater( + PlaceLookup address, + double latitude, + double longitude, + String languageCode, + ) async { + final water = await _lookupBodyOfWater(latitude, longitude, languageCode); + return water == null ? address : address.copyWith(bodyOfWater: water); + } + + /// Best-effort: any failure here leaves the address result untouched. + Future _lookupBodyOfWater( + double latitude, + double longitude, + String languageCode, + ) async { + try { + final json = await _fetchNominatimJson( + buildNaturalFeatureUri(latitude, longitude, languageCode: languageCode), + languageCode, + ); + if (json == null) return null; + final water = bodyOfWaterFromNaturalFeature(json); + _log.info('Natural layer: ${water ?? 'no water feature'}'); + return water; + } catch (e) { + _log.warning('Body of water lookup failed: $e'); + return null; + } + } + /// One Nominatim GET. Returns the decoded object, or null for a non-200 /// status. Lets socket errors propagate so callers can tell "offline" from /// "nothing there". The client is closed in a finally so its sockets are diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index 975596b006..91bcc83fe5 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -18,11 +18,16 @@ import 'package:submersion/core/services/location_service.dart'; /// request the service makes is captured here so the tests can assert on the /// English pin (#214) that lives in the URI *and* in the request headers. class _FakeNominatim { - _FakeNominatim({this.statusCode = 200, this.body = '{}'}); + _FakeNominatim({this.statusCode = 200, this.body = '{}', this.bodyFor}); final int statusCode; final String body; + /// When set, wins over [body] for the given request. + final String? Function(Uri uri)? bodyFor; + + String bodyForUri(Uri uri) => bodyFor?.call(uri) ?? body; + final List requestedUris = []; final List> requestHeaders = >[]; int clientCloseCount = 0; @@ -89,7 +94,7 @@ class _FakeHttpClientRequest implements HttpClientRequest { @override Future close() async { _server.requestHeaders.add((headers as _FakeHttpHeaders).values); - return _FakeHttpClientResponse(_server.statusCode, _server.body); + return _FakeHttpClientResponse(_server.statusCode, _server.bodyForUri(uri)); } @override @@ -201,9 +206,9 @@ void main() { () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), ); - expect(server.requestedUris, hasLength(1)); + expect(server.requestedUris, hasLength(2)); expect( - server.lastUri.queryParameters['accept-language'], + server.requestedUris.first.queryParameters['accept-language'], 'en', reason: 'the request itself must carry the pin, not just the builder', ); @@ -340,8 +345,10 @@ void main() { expect( server.clientCloseCount, - 1, - reason: 'the finally block must release the sockets on the error path', + server.requestedUris.length, + reason: + 'the finally block must release the sockets on the error path, ' + 'once per request (address layer, then natural layer)', ); }); }); @@ -603,6 +610,139 @@ void main() { expect(geocoding.locales, [const Locale('en'), const Locale('en')]); }); }); + + group('body of water (issue #1187)', () { + Map address() => { + 'address': { + 'country': 'Switzerland', + 'state': 'Lucerne', + 'village': 'Weggis', + }, + }; + + String? natural(Uri uri, Map hit) => + uri.queryParameters['layer'] == 'natural' ? jsonEncode(hit) : null; + + test('the natural-layer URI asks for water features only', () { + final uri = LocationService.buildNaturalFeatureUri( + 47.027631, + 8.400640, + languageCode: 'de', + ); + expect(uri.host, 'nominatim.openstreetmap.org'); + expect(uri.path, '/reverse'); + expect(uri.queryParameters['layer'], 'natural'); + expect(uri.queryParameters['zoom'], '14'); + expect(uri.queryParameters['accept-language'], 'de'); + expect(uri.queryParameters['format'], 'json'); + }); + + test('a lake on the natural layer becomes the body of water', () async { + final server = _FakeNominatim( + body: jsonEncode(address()), + bodyFor: (uri) => natural(uri, { + 'class': 'water', + 'type': 'lake', + 'name': 'Lake Lucerne', + }), + ); + + final result = await server.run( + () => service.reverseGeocode(47.027631, 8.400640, languageCode: 'en'), + ); + + expect(result.locality, 'Weggis'); + expect(result.bodyOfWater, 'Lake Lucerne'); + expect(server.requestedUris, hasLength(2)); + expect(server.requestedUris.last.queryParameters['layer'], 'natural'); + }); + + test('a bay is accepted', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'bay', + 'name': 'Naama Bay', + }), + 'Naama Bay', + ); + }); + + test('a strait is accepted', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'strait', + 'name': 'Strait of Gibraltar', + }), + 'Strait of Gibraltar', + ); + }); + + test('a mountain range is not a body of water', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'mountain_range', + 'name': 'Urner Alps', + }), + isNull, + ); + }); + + test('a saddle is not a body of water', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'natural', + 'type': 'saddle', + 'name': 'coll Roig', + }), + isNull, + ); + }); + + test('an unable-to-geocode answer yields no body of water', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'error': 'Unable to geocode', + }), + isNull, + ); + }); + + test('a water hit with a blank name is ignored', () { + expect( + LocationService.bodyOfWaterFromNaturalFeature({ + 'class': 'water', + 'type': 'lake', + 'name': '', + }), + isNull, + ); + }); + + test('a failing natural-layer request keeps the address result', () async { + var calls = 0; + final server = _FakeNominatim( + body: jsonEncode(address()), + bodyFor: (uri) { + if (uri.queryParameters['layer'] != 'natural') return null; + calls++; + return 'this is not json'; + }, + ); + + final result = await server.run( + () => service.reverseGeocode(47.027631, 8.400640, languageCode: 'en'), + ); + + expect(calls, 1); + expect(result.country, 'Switzerland'); + expect(result.locality, 'Weggis'); + expect(result.bodyOfWater, isNull); + expect(result.networkFailed, isFalse); + }); + }); } /// Minimal [GeocodingPlatformFactory] handing out one fake [gpi.Geocoding]. From ed1e666e30a6570bf00d217760b32720a928a768 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:05:02 -0400 Subject: [PATCH 051/122] fix(sync): do not claim an existing reference was deleted Code review caught a bug worse than the one being fixed. isMissing inferred absence from "no name and no date resolved" rather than from the row actually being absent. Six of the reference targets carry neither column, so a record that is present in the database rendered as "No longer in this library": dive tanks (reached from gas switches and tank pressure profiles), data sources, connected accounts, media subscriptions, pre-dive sessions and sightings. A media conflict would have shown two false deletion lines at once, immediately before the user chooses which version to keep. Track existence explicitly from whether fetchRecord returned a row. A present row with no anchor now falls back to a short id, which at least distinguishes the two sides -- and previously hit a null-check crash in the value formatter, since the old code assumed a non-missing reference always had a name or a date. Also from the review: - Name five of those six targets properly by looking at the columns they actually use (tankName, presetName, label, displayName, templateName, sourceFileName), and give sightings the species-name hop that dives already had for their site. - Reuse one resolver for a whole getConflicts() batch and cache the rows it reads. A restore raises many conflicts pointing at the same diver, dive or site, and every one of them was re-fetching the same rows; diverId alone is on 26 tables. - Render measurements in the diver's units. maxDepth was printing bare metres to an imperial diver, which CLAUDE.md forbids. Depth, pressure, temperature and second-valued durations now go through UnitFormatter. - Lead with an entity's own preferred fields and put references after them, so a named record no longer opens with its diver. A junction row has no preferred field, so its references still lead. - Log the quality-finding render fallback instead of degrading silently, and hand out the reference lists unmodifiable. The magnitude floor that separates an epoch column from a duration is now pinned by a direct formatter test rather than incidentally through bottomTime; removing the floor makes that test render 300 as "Dec 31, 1969". --- .../services/sync/conflict_reference.dart | 65 +++++++--- lib/core/services/sync/sync_service.dart | 11 +- .../widgets/conflict_data_preview.dart | 111 +++++++++++++----- .../widgets/conflict_reference_labels.dart | 10 +- .../conflict_reference_resolver_test.dart | 44 +++++++ .../conflict_resolution_dialog_test.dart | 60 +++++++++- .../widgets/conflict_scalar_format_test.dart | 77 ++++++++++++ 7 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 test/features/settings/presentation/widgets/conflict_scalar_format_test.dart diff --git a/lib/core/services/sync/conflict_reference.dart b/lib/core/services/sync/conflict_reference.dart index f68d83fe1d..1b907065a2 100644 --- a/lib/core/services/sync/conflict_reference.dart +++ b/lib/core/services/sync/conflict_reference.dart @@ -12,6 +12,7 @@ class ConflictReference { required this.field, required this.targetType, required this.recordId, + this.exists = true, this.name, this.timestamp, }); @@ -32,10 +33,17 @@ class ConflictReference { /// The referenced row's date anchor, for entities dated rather than named. final DateTime? timestamp; + /// Whether the referenced row is in the local database. Tracked explicitly + /// rather than inferred from [name] and [timestamp] being null: several + /// tables (dive tanks, sightings, connected accounts) can carry neither, and + /// telling a user a record was deleted right before they choose which + /// version to keep is worse than showing them an id. + final bool exists; + /// True when the referenced row is not in the local database: it was deleted /// here, or the conflicting record arrived from a peer that still has it. /// The dialog says so rather than showing a blank line. - bool get isMissing => name == null && timestamp == null; + bool get isMissing => !exists; } /// Resolves the foreign keys of a conflicting record into [ConflictReference]s. @@ -44,10 +52,15 @@ class ConflictReference { /// loads the conflicting row itself, so every entity the serializer can sync is /// resolvable without a second query layer. class ConflictReferenceResolver { - const ConflictReferenceResolver(this._serializer); + ConflictReferenceResolver(this._serializer); final SyncDataSerializer _serializer; + /// Rows already fetched by this resolver. One resolver serves a whole + /// batch of conflicts, and a restore raises many conflicts pointing at the + /// same diver, dive or site, so each referenced row is read once. + final Map?> _rows = {}; + /// Foreign-key column -> sync entity type, transcribed from the /// `.references(Table, #id)` clauses in `database.dart`. Columns whose name /// is ambiguous across tables are disambiguated by [_targetOverrides]. @@ -97,11 +110,20 @@ class ConflictReferenceResolver { }, }; - /// Name-carrying columns, in the order the app prefers them. + /// Name-carrying columns, in the order the app prefers them. Several tables + /// name themselves through a column of their own (a tank's `tankName`, a + /// connected account's `label`), so the generic names are tried first and + /// the table-specific ones after. static const _nameFields = [ 'name', 'title', 'commonName', + 'displayName', + 'label', + 'tankName', + 'presetName', + 'templateName', + 'sourceFileName', 'caption', 'originalFilename', ]; @@ -133,7 +155,7 @@ class ConflictReferenceResolver { if (id is! String || id.isEmpty) continue; references.add(await _resolveOne(entry.key, target, id)); } - return references; + return List.unmodifiable(references); } Future _resolveOne( @@ -141,31 +163,46 @@ class ConflictReferenceResolver { String targetType, String recordId, ) async { - final row = await _serializer.fetchRecord(targetType, recordId); + final row = await _fetch(targetType, recordId); if (row == null) { return ConflictReference( field: field, targetType: targetType, recordId: recordId, + exists: false, ); } return ConflictReference( field: field, targetType: targetType, recordId: recordId, - name: _nameOf(row) ?? await _borrowedSiteName(row), + name: _nameOf(row) ?? await _borrowedName(row), timestamp: _timestampOf(row), ); } - /// An unnamed dive is displayed by its site everywhere else in the app, so - /// borrow that name here too. Exactly one hop: the site's own name is read - /// directly and never resolved further. - Future _borrowedSiteName(Map row) async { - final siteId = row['siteId']; - if (siteId is! String || siteId.isEmpty) return null; - final site = await _serializer.fetchRecord('diveSites', siteId); - return site == null ? null : _nameOf(site); + /// An unnamed dive is displayed by its site everywhere else in the app, and + /// a sighting is only ever known by its species. Borrow those names here + /// too. Exactly one hop: the borrowed row's name is read directly and never + /// resolved further. + Future _borrowedName(Map row) async { + for (final borrow in const [ + (field: 'siteId', target: 'diveSites'), + (field: 'speciesId', target: 'species'), + ]) { + final id = row[borrow.field]; + if (id is! String || id.isEmpty) continue; + final parent = await _fetch(borrow.target, id); + final name = parent == null ? null : _nameOf(parent); + if (name != null) return name; + } + return null; + } + + Future?> _fetch(String targetType, String id) async { + final key = '$targetType|$id'; + if (_rows.containsKey(key)) return _rows[key]; + return _rows[key] = await _serializer.fetchRecord(targetType, id); } static String? _nameOf(Map row) { diff --git a/lib/core/services/sync/sync_service.dart b/lib/core/services/sync/sync_service.dart index 960ab00f22..5fd1999a7f 100644 --- a/lib/core/services/sync/sync_service.dart +++ b/lib/core/services/sync/sync_service.dart @@ -356,6 +356,10 @@ class SyncService { Future> getConflicts() async { final conflictRecords = await _syncRepository.getConflictRecords(); final conflicts = []; + // One resolver for the whole batch: a restore raises many conflicts + // pointing at the same diver, dive or site, and the resolver caches the + // rows it has already read. + final resolver = ConflictReferenceResolver(_serializer); for (final record in conflictRecords) { if (record.conflictData != null) { @@ -379,10 +383,12 @@ class SyncService { DateTime.fromMillisecondsSinceEpoch(record.localUpdatedAt), remoteModified: remoteModified ?? DateTime.now(), localReferences: await _resolveReferences( + resolver, record.entityType, localData ?? {}, ), remoteReferences: await _resolveReferences( + resolver, record.entityType, remoteData, ), @@ -404,14 +410,13 @@ class SyncService { /// failure degrades to an unresolved preview rather than dropping the whole /// conflict, which would leave the user unable to resolve it at all. Future> _resolveReferences( + ConflictReferenceResolver resolver, String entityType, Map data, ) async { if (data.isEmpty) return const []; try { - return await ConflictReferenceResolver( - _serializer, - ).resolve(entityType, data); + return await resolver.resolve(entityType, data); } catch (e) { _log.warning( 'Could not resolve display references for $entityType', diff --git a/lib/features/settings/presentation/widgets/conflict_data_preview.dart b/lib/features/settings/presentation/widgets/conflict_data_preview.dart index 1ef32bcfa5..ece458143b 100644 --- a/lib/features/settings/presentation/widgets/conflict_data_preview.dart +++ b/lib/features/settings/presentation/widgets/conflict_data_preview.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/sync/conflict_reference.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/data_quality/domain/entities/quality_finding.dart'; @@ -16,6 +17,8 @@ import 'package:submersion/features/settings/presentation/widgets/conflict_refer import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/l10n/l10n_extension.dart'; +final _log = LoggerService.forClass(ConflictDataPreview); + /// One labelled line of a conflict's data preview. typedef ConflictPreviewRow = ({String label, String value}); @@ -124,7 +127,22 @@ List conflictPreviewRows({ required Map data, required List references, }) { + final hidden = { + ..._alwaysHidden, + ...?_entityHidden[entityType], + for (final reference in references) reference.field, + }; + final preferred = _preferredScalars(data, hidden); + + ConflictPreviewRow scalarRow(MapEntry entry) => ( + label: entry.key, + value: formatConflictScalar(l10n, units, entry.key, entry.value), + ); + + // A named entity leads with its own name; a junction row has no preferred + // field, so its references lead instead. final rows = [ + for (final entry in preferred.entries) scalarRow(entry), for (final reference in references) ( label: conflictReferenceLabel(l10n, reference), @@ -142,22 +160,36 @@ List conflictPreviewRows({ )); } - final hidden = { - ..._alwaysHidden, - ...?_entityHidden[entityType], - for (final reference in references) reference.field, - }; - for (final entry in _scalarFields(data, hidden).entries) { - rows.add(( - label: entry.key, - value: formatConflictScalar(l10n, units, entry.key, entry.value), - )); + if (preferred.isEmpty) { + for (final entry in _remainingScalars(data, hidden).entries) { + rows.add(scalarRow(entry)); + } } return rows; } -/// Renders a value the way the app renders it elsewhere: epoch millis as a -/// date, a flag as yes/no. Everything else prints as stored. +/// Columns stored in metres, bar and Celsius. Rendering them raw would show a +/// metric number to an imperial diver, so each goes through the diver's own +/// formatter. +const _depthFields = {'maxDepth', 'avgDepth', 'depth'}; +const _pressureFields = { + 'startPressure', + 'endPressure', + 'workingPressure', +}; +const _temperatureFields = { + 'waterTemp', + 'airTemp', + 'temperature', + 'minTemp', +}; + +/// Columns stored as a count of seconds. +const _durationFields = {'bottomTime', 'runtime', 'duration'}; + +/// Renders a value the way the app renders it elsewhere: measurements in the +/// diver's units, epoch millis as a date, a flag as yes/no. Everything else +/// prints as stored. String formatConflictScalar( AppLocalizations l10n, UnitFormatter units, @@ -167,6 +199,16 @@ String formatConflictScalar( if (value is bool) { return value ? l10n.common_action_yes : l10n.common_action_no; } + if (value is num) { + if (_depthFields.contains(key)) return units.formatDepth(value.toDouble()); + if (_pressureFields.contains(key)) { + return units.formatPressure(value.toDouble()); + } + if (_temperatureFields.contains(key)) { + return units.formatTemperature(value.toDouble()); + } + if (_durationFields.contains(key)) return _formatSeconds(value.toInt()); + } if (value is int && _isTimestamp(key, value)) { return units.formatDateTime( DateTime.fromMillisecondsSinceEpoch(value), @@ -176,6 +218,15 @@ String formatConflictScalar( return value.toString(); } +/// A stored count of seconds as "1h 5m" or "45min", matching how the dive +/// field formatter renders a duration elsewhere. +String _formatSeconds(int seconds) { + final totalMinutes = seconds ~/ 60; + final hours = totalMinutes ~/ 60; + final minutes = totalMinutes % 60; + return hours > 0 ? '${hours}h ${minutes}m' : '${minutes}min'; +} + /// True for a column that stores a moment rather than a duration. Both the /// name and the magnitude must agree: `bottomTime` and `runtime` are seconds, /// so only values large enough to be Unix millis are treated as dates. @@ -186,24 +237,27 @@ bool _isTimestamp(String key, int value) { return named && value >= millisFloor; } -Map _scalarFields( +bool _usable(Map data, Set hidden, String key) => + !hidden.contains(key) && data[key] != null; + +Map _preferredScalars( Map data, Set hidden, -) { - bool usable(String key) => !hidden.contains(key) && data[key] != null; - - final preferred = { - for (final key in _preferredFields) - if (data.containsKey(key) && usable(key)) key: data[key], - }; - if (preferred.isNotEmpty) return preferred; +) => { + for (final key in _preferredFields) + if (data.containsKey(key) && _usable(data, hidden, key)) key: data[key], +}; - // Nothing recognizable: show the first few columns that survived the filter, - // which for a junction row is what is left after its foreign keys. +/// Nothing recognizable: show the first few columns that survived the filter, +/// which for a junction row is what is left after its foreign keys. +Map _remainingScalars( + Map data, + Set hidden, +) { final fallback = {}; for (final entry in data.entries) { if (fallback.length >= 5) break; - if (usable(entry.key)) fallback[entry.key] = entry.value; + if (_usable(data, hidden, entry.key)) fallback[entry.key] = entry.value; } return fallback; } @@ -240,11 +294,14 @@ QualityFindingMessage? _findingMessage( ), ); return buildFindingMessage(l10n, finding, formatters); - } on ArgumentError { + } on ArgumentError catch (e) { + _log.warning('Conflict preview could not read a finding row', error: e); return null; - } on FormatException { + } on FormatException catch (e) { + _log.warning('Conflict preview could not read a finding row', error: e); return null; - } on TypeError { + } on TypeError catch (e) { + _log.warning('Conflict preview could not read a finding row', error: e); return null; } } diff --git a/lib/features/settings/presentation/widgets/conflict_reference_labels.dart b/lib/features/settings/presentation/widgets/conflict_reference_labels.dart index e4bd9487ca..18a29aae69 100644 --- a/lib/features/settings/presentation/widgets/conflict_reference_labels.dart +++ b/lib/features/settings/presentation/widgets/conflict_reference_labels.dart @@ -89,7 +89,9 @@ String conflictReferenceLabel( } /// The referenced record in one line: its name, its date, or both. A record -/// that is not in the local library says so rather than rendering blank. +/// that is not in the local library says so rather than rendering blank; one +/// that is present but carries no anchor at all (an unnamed dive tank, say) +/// falls back to a short id, which at least tells the two versions apart. String conflictReferenceValue( AppLocalizations l10n, UnitFormatter units, @@ -103,9 +105,13 @@ String conflictReferenceValue( if (name != null && date != null) { return l10n.settings_conflict_ref_named(name, date); } - return name ?? date!; + return name ?? date ?? shortRecordId(reference.recordId); } +/// The leading segment of a record id, the way a user would quote one. +String shortRecordId(String recordId) => + '#${recordId.length <= 8 ? recordId : recordId.substring(0, 8)}'; + /// Names the conflicting record from the records it points at, for junction /// and relation entities that have no name of their own. Null when nothing /// resolved, so the caller can fall back to the entity type and id. diff --git a/test/core/services/sync/conflict_reference_resolver_test.dart b/test/core/services/sync/conflict_reference_resolver_test.dart index 44fb3bb2d9..8dbea4929b 100644 --- a/test/core/services/sync/conflict_reference_resolver_test.dart +++ b/test/core/services/sync/conflict_reference_resolver_test.dart @@ -202,6 +202,50 @@ void main() { expect(refFor(refs, 'speciesId').name, 'Manta ray'); }); + test('a row that exists is never reported as missing', () async { + // diveTanks carries no name or date column, so "found no anchor" must not + // be read as "row is gone" -- telling a user a record was deleted right + // before they choose what to keep is worse than showing them an id. + await seedDive('dive-1'); + await serializer.upsertRecord('diveTanks', { + 'id': 'tank-1', + 'diveId': 'dive-1', + 'o2Percent': 21.0, + 'hePercent': 0.0, + 'tankOrder': 0, + 'tankRole': 'primary', + }); + + final refs = await resolver.resolve('gasSwitches', { + 'id': 'gs-1', + 'diveId': 'dive-1', + 'tankId': 'tank-1', + }); + + expect(refFor(refs, 'tankId').isMissing, isFalse); + }); + + test('names a tank by its user-facing tank name', () async { + await seedDive('dive-1'); + await serializer.upsertRecord('diveTanks', { + 'id': 'tank-1', + 'diveId': 'dive-1', + 'tankName': 'Primary AL80', + 'o2Percent': 21.0, + 'hePercent': 0.0, + 'tankOrder': 0, + 'tankRole': 'primary', + }); + + final refs = await resolver.resolve('gasSwitches', { + 'id': 'gs-1', + 'diveId': 'dive-1', + 'tankId': 'tank-1', + }); + + expect(refFor(refs, 'tankId').name, 'Primary AL80'); + }); + test('returns nothing for an entity with no foreign keys', () async { final refs = await resolver.resolve('tags', { 'id': 'tag-1', diff --git a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart index 98427b2a0f..cebf4a14ab 100644 --- a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart +++ b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart @@ -72,6 +72,7 @@ void main() { field: 'tagId', targetType: 'tags', recordId: 'a7136f77-5628-4d6c-abaf-eed97f618cc8', + exists: !tagMissing, name: tagMissing ? null : localTagName, ), ], @@ -124,6 +125,43 @@ void main() { expect(find.text('No longer in this library'), findsOneWidget); }); + testWidgets('falls back to a short id for a nameless record that exists', ( + tester, + ) async { + // A dive tank carries no name, date, or any other anchor unless the diver + // named it. The reference still exists, so the preview must identify it + // rather than claim it was deleted. + await pumpDialog( + tester, + SyncConflict( + entityType: 'gasSwitches', + recordId: 'gs-1', + localData: const {'id': 'gs-1', 'tankId': 'aabbccdd-1111-2222'}, + remoteData: const {'id': 'gs-1', 'tankId': 'eeff0011-3333-4444'}, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + localReferences: const [ + ConflictReference( + field: 'tankId', + targetType: 'diveTanks', + recordId: 'aabbccdd-1111-2222', + ), + ], + remoteReferences: const [ + ConflictReference( + field: 'tankId', + targetType: 'diveTanks', + recordId: 'eeff0011-3333-4444', + ), + ], + ), + ); + + expect(find.text('#aabbccdd'), findsOneWidget); + expect(find.text('#eeff0011'), findsOneWidget); + expect(find.text('No longer in this library'), findsNothing); + }); + testWidgets('describes the conflicting record in the header', (tester) async { await pumpDialog(tester, diveTagConflict()); @@ -151,6 +189,25 @@ void main() { expect(find.byIcon(Icons.place), findsOneWidget); }); + testWidgets('renders a depth in the diver configured unit', (tester) async { + await pumpDialog( + tester, + SyncConflict( + entityType: 'dives', + recordId: 'd-1', + localData: const {'id': 'd-1', 'maxDepth': 30.48}, + remoteData: const {'id': 'd-1', 'maxDepth': 30.48}, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + // The mock settings default to metres, so the stored metres carry a unit + // rather than printing as a bare number. + expect(find.text('30.5m'), findsNWidgets(2)); + expect(find.text('30.48'), findsNothing); + }); + testWidgets('dates an epoch column but leaves a duration alone', ( tester, ) async { @@ -180,8 +237,9 @@ void main() { ), ); - expect(find.text('2700'), findsNWidgets(2)); + expect(find.text('45min'), findsNWidgets(2)); expect(find.textContaining('1786556582600'), findsNothing); + expect(find.textContaining('2700'), findsNothing); }); testWidgets('renders a quality finding as its localized message', ( diff --git a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart new file mode 100644 index 0000000000..66fcbcbffc --- /dev/null +++ b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/conflict_data_preview.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Unit coverage for the conflict preview's scalar formatter (#1031). The +/// dialog used to print every column's stored value verbatim, which showed a +/// metric depth to an imperial diver and an epoch integer to everyone. +void main() { + late AppLocalizations l10n; + + setUpAll(() async { + l10n = await AppLocalizations.delegate.load(const Locale('en')); + }); + + String format(UnitFormatter units, String key, Object value) => + formatConflictScalar(l10n, units, key, value); + + group('metric diver', () { + const units = UnitFormatter(AppSettings()); + + test('renders a depth in metres with its symbol', () { + expect(format(units, 'maxDepth', 30.48), '30.5m'); + }); + + test('renders a stored duration as hours and minutes', () { + expect(format(units, 'bottomTime', 2700), '45min'); + expect(format(units, 'runtime', 4500), '1h 15m'); + }); + + test('renders a flag as words', () { + expect(format(units, 'isShared', true), 'Yes'); + expect(format(units, 'isShared', false), 'No'); + }); + + test('leaves a plain column alone', () { + expect(format(units, 'diveNumber', 12), '12'); + expect(format(units, 'notes', 'Strong current'), 'Strong current'); + }); + }); + + test('converts to the diver own units rather than the stored ones', () { + const imperial = UnitFormatter( + AppSettings( + depthUnit: DepthUnit.feet, + pressureUnit: PressureUnit.psi, + temperatureUnit: TemperatureUnit.fahrenheit, + ), + ); + + expect(format(imperial, 'maxDepth', 30.48), '100.0ft'); + expect(format(imperial, 'startPressure', 200.0), '2901 psi'); + expect(format(imperial, 'waterTemp', 20.0), '68°F'); + }); + + group('epoch columns', () { + const units = UnitFormatter(AppSettings()); + + test('renders a time-named column holding Unix millis as a date', () { + final formatted = format(units, 'createdAt', 1786556582600); + expect(formatted, isNot(contains('1786556582600'))); + expect(formatted, contains('2026')); + }); + + test('leaves a time-named column too small to be Unix millis alone', () { + // Every time-named column in today's schema really is an epoch value, + // so this rule is defensive: it makes the formatter fail safe. A future + // column holding a small count under a time-ish name renders as the + // number it is rather than being dated to 1970. + expect(format(units, 'surfaceIntervalTime', 300), '300'); + expect(format(units, 'holdTime', 90), '90'); + }); + }); +} From 2297b48304658a278ab4f9b876c2c0fe5cd45c75 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:07:56 -0400 Subject: [PATCH 052/122] feat(location): space Nominatim requests one second apart (#1187) --- .../geocoding/nominatim_throttle.dart | 37 + lib/core/services/location_service.dart | 8 + .../geocoding/nominatim_throttle_test.dart | 83 ++ test/core/services/location_service_test.dart | 32 + test/integration/uddf_round_trip_test.dart | 1130 +++++++++-------- 5 files changed, 727 insertions(+), 563 deletions(-) create mode 100644 lib/core/services/geocoding/nominatim_throttle.dart create mode 100644 test/core/services/geocoding/nominatim_throttle_test.dart diff --git a/lib/core/services/geocoding/nominatim_throttle.dart b/lib/core/services/geocoding/nominatim_throttle.dart new file mode 100644 index 0000000000..d33c889681 --- /dev/null +++ b/lib/core/services/geocoding/nominatim_throttle.dart @@ -0,0 +1,37 @@ +import 'package:clock/clock.dart'; + +/// Spaces Nominatim requests at least [minimumGap] apart, process-wide. +/// +/// OpenStreetMap's usage policy allows one request per second. A single +/// interactive lookup makes two requests (address layer, then natural +/// layer) and the bulk backfill makes hundreds, so the spacing lives in one +/// place instead of at every call site. Waiters are released in call order. +/// +/// Uses `clock.now()` rather than `Stopwatch` so fake_async tests can drive +/// it; a `Stopwatch` is invisible to the synthetic clock. +class NominatimThrottle { + NominatimThrottle({this.minimumGap = const Duration(seconds: 1)}); + + final Duration minimumGap; + + DateTime? _lastRelease; + Future _queue = Future.value(); + + /// Completes when the caller may send its request. + Future wait() { + final turn = _queue.then((_) => _holdUntilGapElapsed()); + _queue = turn; + return turn; + } + + Future _holdUntilGapElapsed() async { + final last = _lastRelease; + if (last != null) { + final sinceLast = clock.now().difference(last); + if (sinceLast < minimumGap) { + await Future.delayed(minimumGap - sinceLast); + } + } + _lastRelease = clock.now(); + } +} diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index a821cf0422..a095542ed9 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; import 'package:geolocator/geolocator.dart'; import 'package:geocoding/geocoding.dart'; +import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/logger_service.dart'; @@ -113,6 +114,11 @@ class LocationService { static bool get _useNativeGeocoder => debugForceNativeGeocoder || _isMobile; + /// Process-wide spacing for every Nominatim request. Tests replace it with + /// a zero-gap instance so lookups do not wait a real second each. + @visibleForTesting + static NominatimThrottle throttle = NominatimThrottle(); + static final _log = LoggerService.forClass(LocationService); static LocationService? _instance; @@ -393,6 +399,7 @@ class LocationService { Uri url, String languageCode, ) async { + await throttle.wait(); final client = HttpClient(); client.userAgent = 'Submersion Dive Log App'; try { @@ -419,6 +426,7 @@ class LocationService { final url = buildForwardGeocodeUri(address); + await throttle.wait(); final client = HttpClient(); client.userAgent = 'Submersion Dive Log App'; diff --git a/test/core/services/geocoding/nominatim_throttle_test.dart b/test/core/services/geocoding/nominatim_throttle_test.dart new file mode 100644 index 0000000000..bec266ee6f --- /dev/null +++ b/test/core/services/geocoding/nominatim_throttle_test.dart @@ -0,0 +1,83 @@ +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; + +void main() { + test('the first request goes through immediately', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + var released = false; + throttle.wait().then((_) => released = true); + async.flushMicrotasks(); + expect(released, isTrue); + }); + }); + + test('a second request waits until a second has passed', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + final releasedAt = []; + final start = clock.now(); + throttle.wait().then( + (_) => releasedAt.add(clock.now().difference(start)), + ); + throttle.wait().then( + (_) => releasedAt.add(clock.now().difference(start)), + ); + async.flushMicrotasks(); + expect(releasedAt, [Duration.zero]); + + async.elapse(const Duration(milliseconds: 999)); + expect(releasedAt, hasLength(1)); + + async.elapse(const Duration(milliseconds: 1)); + expect(releasedAt, [Duration.zero, const Duration(seconds: 1)]); + }); + }); + + test('requests spaced wider than the gap are not delayed', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + throttle.wait(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 3)); + + var released = false; + throttle.wait().then((_) => released = true); + async.flushMicrotasks(); + expect(released, isTrue); + }); + }); + + test('three queued requests are released one second apart', () { + fakeAsync((async) { + final throttle = NominatimThrottle(); + final start = clock.now(); + final releasedAt = []; + for (var i = 0; i < 3; i++) { + throttle.wait().then( + (_) => releasedAt.add(clock.now().difference(start)), + ); + } + async.elapse(const Duration(seconds: 2)); + expect(releasedAt, [ + Duration.zero, + const Duration(seconds: 1), + const Duration(seconds: 2), + ]); + }); + }); + + test('a zero gap never delays', () { + fakeAsync((async) { + final throttle = NominatimThrottle(minimumGap: Duration.zero); + var count = 0; + for (var i = 0; i < 5; i++) { + throttle.wait().then((_) => count++); + } + async.flushMicrotasks(); + expect(count, 5); + }); + }); +} diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index 91bcc83fe5..22368eb102 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -3,12 +3,16 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui' show Locale; +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:geocoding/geocoding.dart'; // `geocoding` declares its own app-facing `Geocoding`, which shadows the // platform-interface class of the same name that fakes must extend. import 'package:geocoding_platform_interface/geocoding_platform_interface.dart' as gpi; +import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/location_service.dart'; /// One canned HTTP exchange plus a record of what the service actually sent. @@ -144,6 +148,10 @@ class _FakeHttpClientResponse extends Stream> void main() { final service = LocationService.instance; + setUp(() { + LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + }); + group('Nominatim URIs pin English results (#214)', () { test('reverse geocode URI carries the requested accept-language', () { final uri = LocationService.buildReverseGeocodeUri( @@ -742,6 +750,30 @@ void main() { expect(result.bodyOfWater, isNull); expect(result.networkFailed, isFalse); }); + + test('the address and natural requests are a second apart', () { + fakeAsync((async) { + LocationService.throttle = NominatimThrottle(); + final start = clock.now(); + final seenAt = []; + final server = _FakeNominatim( + body: jsonEncode(address()), + bodyFor: (uri) { + seenAt.add(clock.now().difference(start)); + return uri.queryParameters['layer'] == 'natural' + ? jsonEncode({'class': 'water', 'type': 'lake', 'name': 'L'}) + : null; + }, + ); + PlaceLookup? result; + server + .run(() => service.reverseGeocode(47.0, 8.4, languageCode: 'en')) + .then((r) => result = r); + async.elapse(const Duration(seconds: 1)); + expect(seenAt, [Duration.zero, const Duration(seconds: 1)]); + expect(result?.bodyOfWater, 'L'); + }); + }); }); } diff --git a/test/integration/uddf_round_trip_test.dart b/test/integration/uddf_round_trip_test.dart index d5ebe7ff4c..07fc8d5039 100644 --- a/test/integration/uddf_round_trip_test.dart +++ b/test/integration/uddf_round_trip_test.dart @@ -1,563 +1,567 @@ -// UDDF Import/Export Round-Trip Integration Test -// -// Tests that data can be: -// 1. Generated via Python script -// 2. Imported into the app database -// 3. Exported back to UDDF -// 4. Re-parsed with semantic equivalence to original -// -// This validates the integrity of the UDDF import/export pipeline. - -import 'dart:io'; - -import 'package:drift/native.dart'; -import 'package:flutter/services.dart'; -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/core/services/export/export_service.dart'; -import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; -import 'package:submersion/features/buddies/domain/entities/buddy.dart'; -import 'package:submersion/features/certifications/data/repositories/certification_repository.dart'; -import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; -import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart' - as dive_entity; -import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; -import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; -import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; -import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; -import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; -import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; -import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; -import 'package:submersion/features/tags/domain/entities/tag.dart' - as tag_entity; -import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; - -import '../helpers/python_script_runner.dart'; -import '../helpers/uddf_comparison_helper.dart'; -import 'uddf_test_importer.dart'; - -void main() { - late AppDatabase testDb; - late ExportService exportService; - late Directory tempDir; - - setUpAll(() async { - TestWidgetsFlutterBinding.ensureInitialized(); - - // Create temp directory for test files - tempDir = await Directory.systemTemp.createTemp('uddf_round_trip_test_'); - - // Mock path_provider for ExportService file operations - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('plugins.flutter.io/path_provider'), - (MethodCall methodCall) async { - if (methodCall.method == 'getApplicationDocumentsDirectory') { - return tempDir.path; - } - return null; - }, - ); - - // Mock share_plus (required by ExportService but not used in tests) - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('dev.fluttercommunity.plus/share'), - (MethodCall methodCall) async => null, - ); - }); - - setUp(() async { - // Create fresh in-memory database for each test - testDb = AppDatabase(NativeDatabase.memory()); - DatabaseService.instance.setTestDatabase(testDb); - exportService = ExportService(); - }); - - tearDown(() async { - await testDb.close(); - DatabaseService.instance.resetForTesting(); - }); - - tearDownAll(() async { - // Clean up temp directory - if (await tempDir.exists()) { - await tempDir.delete(recursive: true); - } - }); - - /// Creates a [UddfTestImporter] with all repositories initialized. - UddfTestImporter createImporter() { - return UddfTestImporter( - diverRepository: DiverRepository(), - diveRepository: DiveRepository(), - siteRepository: SiteRepository(), - buddyRepository: BuddyRepository(), - equipmentRepository: EquipmentRepository(), - equipmentSetRepository: EquipmentSetRepository(), - tripRepository: TripRepository(), - diveCenterRepository: DiveCenterRepository(), - certificationRepository: CertificationRepository(), - tagRepository: TagRepository(), - diveTypeRepository: DiveTypeRepository(), - tankPressureRepository: TankPressureRepository(), - ); - } - - group('UDDF Round-Trip', () { - test('import -> export preserves semantic data integrity', () async { - // STEP 1: Generate test UDDF file using Python script - final originalUddfPath = await PythonScriptRunner.generateUddfTestData( - quick: true, - outputPath: '${tempDir.path}/original.uddf', - ); - - final originalContent = await File(originalUddfPath).readAsString(); - - // STEP 2: Parse original UDDF to get baseline data - final originalResult = await exportService.importAllDataFromUddf( - originalContent, - ); - final normalizedOriginal = UddfComparisonHelper.normalizeImportResult( - originalResult, - ); - - // Verify original has expected data - expect( - originalResult.dives.length, - greaterThan(0), - reason: 'Original UDDF should contain dives', - ); - expect( - originalResult.sites.length, - greaterThan(0), - reason: 'Original UDDF should contain sites', - ); - - // Print summary for debugging - // ignore: avoid_print - print( - 'Original data:\n${UddfComparisonHelper.summarize(normalizedOriginal)}', - ); - - // STEP 3: Import into database - final importer = createImporter(); - await importer.importFromContent(originalContent); - - // STEP 4: Fetch all data from database and export back to UDDF - final diveRepository = DiveRepository(); - final siteRepository = SiteRepository(); - final buddyRepository = BuddyRepository(); - final equipmentRepository = EquipmentRepository(); - final tripRepository = TripRepository(); - final tagRepository = TagRepository(); - final certificationRepository = CertificationRepository(); - final diveCenterRepository = DiveCenterRepository(); - final equipmentSetRepository = EquipmentSetRepository(); - final diveTypeRepository = DiveTypeRepository(); - final diverRepository = DiverRepository(); - - // Get all dives, then load full data including profiles for each - final divesWithoutProfiles = await diveRepository.getAllDives(); - final dives = []; - for (final dive in divesWithoutProfiles) { - // Load profile data for each dive (getAllDives doesn't include profiles) - final profile = await diveRepository.getDiveProfile(dive.id); - dives.add(dive.copyWith(profile: profile)); - } - final sites = await siteRepository.getAllSites(); - final buddies = await buddyRepository.getAllBuddies(); - final equipment = await equipmentRepository.getAllEquipment(); - final trips = await tripRepository.getAllTrips(); - final tags = await tagRepository.getAllTags(); - final certifications = await certificationRepository - .getAllCertifications(); - final diveCenters = await diveCenterRepository.getAllDiveCenters(); - final equipmentSets = await equipmentSetRepository.getAllSets(); - final diveTypes = await diveTypeRepository.getAllDiveTypes(); - final diver = await diverRepository.getDefaultDiver(); - - // Get buddy and tag associations per dive - final diveBuddies = >{}; - final diveTags = >{}; - for (final dive in dives) { - diveBuddies[dive.id] = await buddyRepository.getBuddiesForDive(dive.id); - diveTags[dive.id] = await tagRepository.getTagsForDive(dive.id); - } - - final exportedUddfPath = await exportService.exportAllDataToUddf( - dives: dives, - sites: sites, - buddies: buddies, - equipment: equipment, - trips: trips, - tags: tags, - certifications: certifications, - diveCenters: diveCenters, - equipmentSets: equipmentSets, - customDiveTypes: diveTypes.where((t) => !t.isBuiltIn).toList(), - owner: diver, - diveBuddies: diveBuddies, - diveTags: diveTags, - ); - - // STEP 5: Parse exported UDDF - final exportedContent = await File(exportedUddfPath).readAsString(); - final exportedResult = await exportService.importAllDataFromUddf( - exportedContent, - ); - final normalizedExported = UddfComparisonHelper.normalizeImportResult( - exportedResult, - ); - - // Print summary for debugging - // ignore: avoid_print - print( - 'Exported data:\n${UddfComparisonHelper.summarize(normalizedExported)}', - ); - - // STEP 6: Compare semantic content - final differences = UddfComparisonHelper.compareResults( - normalizedOriginal, - normalizedExported, - ); - - // Assert no significant differences - if (differences.isNotEmpty) { - // ignore: avoid_print - print('Differences found:\n${differences.take(20).join('\n')}'); - if (differences.length > 20) { - // ignore: avoid_print - print('... and ${differences.length - 20} more differences'); - } - } - - expect( - differences, - isEmpty, - reason: - 'Round-trip should preserve data. ' - 'Found ${differences.length} differences.', - ); - - // Additional specific checks - expect( - normalizedExported['diveCount'], - equals(normalizedOriginal['diveCount']), - reason: 'Dive count should match', - ); - expect( - normalizedExported['siteCount'], - equals(normalizedOriginal['siteCount']), - reason: 'Site count should match', - ); - expect( - normalizedExported['buddyCount'], - equals(normalizedOriginal['buddyCount']), - reason: 'Buddy count should match', - ); - expect( - normalizedExported['tripCount'], - equals(normalizedOriginal['tripCount']), - reason: 'Trip count should match', - ); - }); - - test('preserves dive profile data through round-trip', () async { - // Generate and import - final originalUddfPath = await PythonScriptRunner.generateUddfTestData( - quick: true, - outputPath: '${tempDir.path}/profile_test.uddf', - ); - - final content = await File(originalUddfPath).readAsString(); - final originalResult = await exportService.importAllDataFromUddf(content); - - // Count dives with profile data in original - var originalDivesWithProfiles = 0; - var originalTotalPoints = 0; - for (final dive in originalResult.dives) { - final profile = dive['profile'] as List?; - if (profile != null && profile.isNotEmpty) { - originalDivesWithProfiles++; - originalTotalPoints += profile.length; - } - } - - expect( - originalDivesWithProfiles, - greaterThan(0), - reason: 'Original should have dives with profile data', - ); - - // Import, export, re-parse - final importer = createImporter(); - await importer.importFromContent(content); - - final diveRepository = DiveRepository(); - final divesWithoutProfiles = await diveRepository.getAllDives(); - - // Load profile data for each dive (getAllDives doesn't include profiles) - final dives = []; - for (final dive in divesWithoutProfiles) { - final profile = await diveRepository.getDiveProfile(dive.id); - dives.add(dive.copyWith(profile: profile)); - } - - final exportedPath = await exportService.exportAllDataToUddf( - dives: dives, - ); - final exportedContent = await File(exportedPath).readAsString(); - final exportedResult = await exportService.importAllDataFromUddf( - exportedContent, - ); - - // Verify profile data survives round-trip - var exportedDivesWithProfiles = 0; - var exportedTotalPoints = 0; - for (final dive in exportedResult.dives) { - final profile = dive['profile'] as List?; - if (profile != null && profile.isNotEmpty) { - exportedDivesWithProfiles++; - exportedTotalPoints += profile.length; - } - } - - // All dives that had profiles should still have profiles - expect( - exportedDivesWithProfiles, - equals(originalDivesWithProfiles), - reason: 'Same number of dives should have profile data', - ); - - // Profile data should be fully preserved (100% retention) - // Export may add 1 extra waypoint per dive for tank switch at t=0 - expect( - exportedTotalPoints, - greaterThanOrEqualTo(originalTotalPoints), - reason: - '100% of profile points should survive round-trip ' - '(original: $originalTotalPoints, exported: $exportedTotalPoints)', - ); - }); - - test('preserves tank data through round-trip', () async { - // Generate and import - final originalUddfPath = await PythonScriptRunner.generateUddfTestData( - quick: true, - outputPath: '${tempDir.path}/tank_test.uddf', - ); - - final content = await File(originalUddfPath).readAsString(); - final originalResult = await exportService.importAllDataFromUddf(content); - - // Count dives with tank data in original - var originalTankCount = 0; - for (final dive in originalResult.dives) { - final tanks = dive['tanks'] as List?; - if (tanks != null) { - originalTankCount += tanks.length; - } - } - - // Import, export, re-parse - final importer = createImporter(); - await importer.importFromContent(content); - - final diveRepository = DiveRepository(); - final divesWithoutProfiles = await diveRepository.getAllDives(); - - // Load profile data for each dive (needed for complete export) - final dives = []; - for (final dive in divesWithoutProfiles) { - final profile = await diveRepository.getDiveProfile(dive.id); - dives.add(dive.copyWith(profile: profile)); - } - - final exportedPath = await exportService.exportAllDataToUddf( - dives: dives, - ); - final exportedContent = await File(exportedPath).readAsString(); - final exportedResult = await exportService.importAllDataFromUddf( - exportedContent, - ); - - // Count tanks in exported - var exportedTankCount = 0; - for (final dive in exportedResult.dives) { - final tanks = dive['tanks'] as List?; - if (tanks != null) { - exportedTankCount += tanks.length; - } - } - - expect( - exportedTankCount, - equals(originalTankCount), - reason: 'Total tank count should match through round-trip', - ); - }); - - test('imports multi-tank dive and stores separate pressure data', () async { - // 1. Manually create a UDDF string for a two-tank dive - const uddfContent = ''' - - - - - - 2024-01-01T12:00:00 - 1 - - - 12.0 - - - 11.0 - - - - 10 - 10.0 - 20000000 - 19000000 - - - 20 - 20.0 - 18000000 - 17000000 - - - - - - -'''; - - // 2. Import the data into the database - final importer = createImporter(); - await importer.importFromContent(uddfContent); - - // 3. Verify the dive was created - final diveRepository = DiveRepository(); - final dives = await diveRepository.getAllDives(); - expect(dives, hasLength(1)); - final diveId = dives.first.id; - - // 4. Query the tank pressure data from the repository - final tankPressureRepository = TankPressureRepository(); - final pressuresByTank = await tankPressureRepository - .getTankPressuresForDive(diveId); - - // 5. Assert that pressure data for two separate tanks was stored - expect( - pressuresByTank.keys, - hasLength(2), - reason: 'Should have pressure data for two tanks.', - ); - - final pressureSeries = pressuresByTank.values.toList() - ..sort((a, b) => a.first.pressure.compareTo(b.first.pressure)); - final pressuresLower = pressureSeries[0]; - final pressuresHigher = pressureSeries[1]; - - expect(pressuresLower, hasLength(2)); - expect(pressuresHigher, hasLength(2)); - expect(pressuresHigher.first.pressure, closeTo(200.0, 0.1)); - expect(pressuresLower.first.pressure, closeTo(190.0, 0.1)); - }); - - test( - 'imports multi-tank dive without tank IDs and stores separate pressure data', - () async { - // Test case for UDDF files where tankdata elements don't have id attributes - // but waypoints reference tanks as "T1", "T2", etc. (like Perdix AI exports) - const uddfContent = ''' - - - - - - 235 - 2025-09-01T14:18:24Z - - - 20049962 - 12879411 - - - 21952916 - 14244574 - - - 0 - 0 - - - 0 - 0 - - - - 1 - 0 - 20049962 - 21952916 - - - 3 - 10 - 19939646 - 21939126 - - - - - - -'''; - - // 2. Import the data into the database - final importer = createImporter(); - await importer.importFromContent(uddfContent); - - // 3. Verify the dive was created - final diveRepository = DiveRepository(); - final dives = await diveRepository.getAllDives(); - expect(dives, hasLength(1)); - final diveId = dives.first.id; - - // 4. Query the tank pressure data from the repository - final tankPressureRepository = TankPressureRepository(); - final pressuresByTank = await tankPressureRepository - .getTankPressuresForDive(diveId); - - // 5. Assert that pressure data for two separate tanks was stored - // (tanks 3-4 have zero pressures and should be filtered out) - expect( - pressuresByTank.keys, - hasLength(2), - reason: - 'Should have pressure data for two tanks with non-zero pressures.', - ); - - final pressureSeries = pressuresByTank.values.toList() - ..sort((a, b) => a.first.pressure.compareTo(b.first.pressure)); - final pressuresT1 = pressureSeries[0]; - final pressuresT2 = pressureSeries[1]; - - expect(pressuresT1, hasLength(2)); - expect(pressuresT2, hasLength(2)); - // First waypoint pressures - expect(pressuresT1.first.pressure, closeTo(200.5, 0.1)); - expect(pressuresT2.first.pressure, closeTo(219.5, 0.1)); - // Second waypoint pressures - expect(pressuresT1.last.pressure, closeTo(199.4, 0.1)); - expect(pressuresT2.last.pressure, closeTo(219.4, 0.1)); - }, - ); - }); -} +// UDDF Import/Export Round-Trip Integration Test +// +// Tests that data can be: +// 1. Generated via Python script +// 2. Imported into the app database +// 3. Exported back to UDDF +// 4. Re-parsed with semantic equivalence to original +// +// This validates the integrity of the UDDF import/export pipeline. + +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter/services.dart'; +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/core/services/export/export_service.dart'; +import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/buddies/domain/entities/buddy.dart'; +import 'package:submersion/features/certifications/data/repositories/certification_repository.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as dive_entity; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; +import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; +import 'package:submersion/features/tags/domain/entities/tag.dart' + as tag_entity; +import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; + +import '../helpers/python_script_runner.dart'; +import '../helpers/uddf_comparison_helper.dart'; +import 'uddf_test_importer.dart'; + +void main() { + late AppDatabase testDb; + late ExportService exportService; + late Directory tempDir; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Create temp directory for test files + tempDir = await Directory.systemTemp.createTemp('uddf_round_trip_test_'); + + // Mock path_provider for ExportService file operations + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (MethodCall methodCall) async { + if (methodCall.method == 'getApplicationDocumentsDirectory') { + return tempDir.path; + } + return null; + }, + ); + + // Mock share_plus (required by ExportService but not used in tests) + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/share'), + (MethodCall methodCall) async => null, + ); + }); + + setUp(() async { + // Nominatim spacing would add a real second per geocode here. + LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + // Create fresh in-memory database for each test + testDb = AppDatabase(NativeDatabase.memory()); + DatabaseService.instance.setTestDatabase(testDb); + exportService = ExportService(); + }); + + tearDown(() async { + await testDb.close(); + DatabaseService.instance.resetForTesting(); + }); + + tearDownAll(() async { + // Clean up temp directory + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + /// Creates a [UddfTestImporter] with all repositories initialized. + UddfTestImporter createImporter() { + return UddfTestImporter( + diverRepository: DiverRepository(), + diveRepository: DiveRepository(), + siteRepository: SiteRepository(), + buddyRepository: BuddyRepository(), + equipmentRepository: EquipmentRepository(), + equipmentSetRepository: EquipmentSetRepository(), + tripRepository: TripRepository(), + diveCenterRepository: DiveCenterRepository(), + certificationRepository: CertificationRepository(), + tagRepository: TagRepository(), + diveTypeRepository: DiveTypeRepository(), + tankPressureRepository: TankPressureRepository(), + ); + } + + group('UDDF Round-Trip', () { + test('import -> export preserves semantic data integrity', () async { + // STEP 1: Generate test UDDF file using Python script + final originalUddfPath = await PythonScriptRunner.generateUddfTestData( + quick: true, + outputPath: '${tempDir.path}/original.uddf', + ); + + final originalContent = await File(originalUddfPath).readAsString(); + + // STEP 2: Parse original UDDF to get baseline data + final originalResult = await exportService.importAllDataFromUddf( + originalContent, + ); + final normalizedOriginal = UddfComparisonHelper.normalizeImportResult( + originalResult, + ); + + // Verify original has expected data + expect( + originalResult.dives.length, + greaterThan(0), + reason: 'Original UDDF should contain dives', + ); + expect( + originalResult.sites.length, + greaterThan(0), + reason: 'Original UDDF should contain sites', + ); + + // Print summary for debugging + // ignore: avoid_print + print( + 'Original data:\n${UddfComparisonHelper.summarize(normalizedOriginal)}', + ); + + // STEP 3: Import into database + final importer = createImporter(); + await importer.importFromContent(originalContent); + + // STEP 4: Fetch all data from database and export back to UDDF + final diveRepository = DiveRepository(); + final siteRepository = SiteRepository(); + final buddyRepository = BuddyRepository(); + final equipmentRepository = EquipmentRepository(); + final tripRepository = TripRepository(); + final tagRepository = TagRepository(); + final certificationRepository = CertificationRepository(); + final diveCenterRepository = DiveCenterRepository(); + final equipmentSetRepository = EquipmentSetRepository(); + final diveTypeRepository = DiveTypeRepository(); + final diverRepository = DiverRepository(); + + // Get all dives, then load full data including profiles for each + final divesWithoutProfiles = await diveRepository.getAllDives(); + final dives = []; + for (final dive in divesWithoutProfiles) { + // Load profile data for each dive (getAllDives doesn't include profiles) + final profile = await diveRepository.getDiveProfile(dive.id); + dives.add(dive.copyWith(profile: profile)); + } + final sites = await siteRepository.getAllSites(); + final buddies = await buddyRepository.getAllBuddies(); + final equipment = await equipmentRepository.getAllEquipment(); + final trips = await tripRepository.getAllTrips(); + final tags = await tagRepository.getAllTags(); + final certifications = await certificationRepository + .getAllCertifications(); + final diveCenters = await diveCenterRepository.getAllDiveCenters(); + final equipmentSets = await equipmentSetRepository.getAllSets(); + final diveTypes = await diveTypeRepository.getAllDiveTypes(); + final diver = await diverRepository.getDefaultDiver(); + + // Get buddy and tag associations per dive + final diveBuddies = >{}; + final diveTags = >{}; + for (final dive in dives) { + diveBuddies[dive.id] = await buddyRepository.getBuddiesForDive(dive.id); + diveTags[dive.id] = await tagRepository.getTagsForDive(dive.id); + } + + final exportedUddfPath = await exportService.exportAllDataToUddf( + dives: dives, + sites: sites, + buddies: buddies, + equipment: equipment, + trips: trips, + tags: tags, + certifications: certifications, + diveCenters: diveCenters, + equipmentSets: equipmentSets, + customDiveTypes: diveTypes.where((t) => !t.isBuiltIn).toList(), + owner: diver, + diveBuddies: diveBuddies, + diveTags: diveTags, + ); + + // STEP 5: Parse exported UDDF + final exportedContent = await File(exportedUddfPath).readAsString(); + final exportedResult = await exportService.importAllDataFromUddf( + exportedContent, + ); + final normalizedExported = UddfComparisonHelper.normalizeImportResult( + exportedResult, + ); + + // Print summary for debugging + // ignore: avoid_print + print( + 'Exported data:\n${UddfComparisonHelper.summarize(normalizedExported)}', + ); + + // STEP 6: Compare semantic content + final differences = UddfComparisonHelper.compareResults( + normalizedOriginal, + normalizedExported, + ); + + // Assert no significant differences + if (differences.isNotEmpty) { + // ignore: avoid_print + print('Differences found:\n${differences.take(20).join('\n')}'); + if (differences.length > 20) { + // ignore: avoid_print + print('... and ${differences.length - 20} more differences'); + } + } + + expect( + differences, + isEmpty, + reason: + 'Round-trip should preserve data. ' + 'Found ${differences.length} differences.', + ); + + // Additional specific checks + expect( + normalizedExported['diveCount'], + equals(normalizedOriginal['diveCount']), + reason: 'Dive count should match', + ); + expect( + normalizedExported['siteCount'], + equals(normalizedOriginal['siteCount']), + reason: 'Site count should match', + ); + expect( + normalizedExported['buddyCount'], + equals(normalizedOriginal['buddyCount']), + reason: 'Buddy count should match', + ); + expect( + normalizedExported['tripCount'], + equals(normalizedOriginal['tripCount']), + reason: 'Trip count should match', + ); + }); + + test('preserves dive profile data through round-trip', () async { + // Generate and import + final originalUddfPath = await PythonScriptRunner.generateUddfTestData( + quick: true, + outputPath: '${tempDir.path}/profile_test.uddf', + ); + + final content = await File(originalUddfPath).readAsString(); + final originalResult = await exportService.importAllDataFromUddf(content); + + // Count dives with profile data in original + var originalDivesWithProfiles = 0; + var originalTotalPoints = 0; + for (final dive in originalResult.dives) { + final profile = dive['profile'] as List?; + if (profile != null && profile.isNotEmpty) { + originalDivesWithProfiles++; + originalTotalPoints += profile.length; + } + } + + expect( + originalDivesWithProfiles, + greaterThan(0), + reason: 'Original should have dives with profile data', + ); + + // Import, export, re-parse + final importer = createImporter(); + await importer.importFromContent(content); + + final diveRepository = DiveRepository(); + final divesWithoutProfiles = await diveRepository.getAllDives(); + + // Load profile data for each dive (getAllDives doesn't include profiles) + final dives = []; + for (final dive in divesWithoutProfiles) { + final profile = await diveRepository.getDiveProfile(dive.id); + dives.add(dive.copyWith(profile: profile)); + } + + final exportedPath = await exportService.exportAllDataToUddf( + dives: dives, + ); + final exportedContent = await File(exportedPath).readAsString(); + final exportedResult = await exportService.importAllDataFromUddf( + exportedContent, + ); + + // Verify profile data survives round-trip + var exportedDivesWithProfiles = 0; + var exportedTotalPoints = 0; + for (final dive in exportedResult.dives) { + final profile = dive['profile'] as List?; + if (profile != null && profile.isNotEmpty) { + exportedDivesWithProfiles++; + exportedTotalPoints += profile.length; + } + } + + // All dives that had profiles should still have profiles + expect( + exportedDivesWithProfiles, + equals(originalDivesWithProfiles), + reason: 'Same number of dives should have profile data', + ); + + // Profile data should be fully preserved (100% retention) + // Export may add 1 extra waypoint per dive for tank switch at t=0 + expect( + exportedTotalPoints, + greaterThanOrEqualTo(originalTotalPoints), + reason: + '100% of profile points should survive round-trip ' + '(original: $originalTotalPoints, exported: $exportedTotalPoints)', + ); + }); + + test('preserves tank data through round-trip', () async { + // Generate and import + final originalUddfPath = await PythonScriptRunner.generateUddfTestData( + quick: true, + outputPath: '${tempDir.path}/tank_test.uddf', + ); + + final content = await File(originalUddfPath).readAsString(); + final originalResult = await exportService.importAllDataFromUddf(content); + + // Count dives with tank data in original + var originalTankCount = 0; + for (final dive in originalResult.dives) { + final tanks = dive['tanks'] as List?; + if (tanks != null) { + originalTankCount += tanks.length; + } + } + + // Import, export, re-parse + final importer = createImporter(); + await importer.importFromContent(content); + + final diveRepository = DiveRepository(); + final divesWithoutProfiles = await diveRepository.getAllDives(); + + // Load profile data for each dive (needed for complete export) + final dives = []; + for (final dive in divesWithoutProfiles) { + final profile = await diveRepository.getDiveProfile(dive.id); + dives.add(dive.copyWith(profile: profile)); + } + + final exportedPath = await exportService.exportAllDataToUddf( + dives: dives, + ); + final exportedContent = await File(exportedPath).readAsString(); + final exportedResult = await exportService.importAllDataFromUddf( + exportedContent, + ); + + // Count tanks in exported + var exportedTankCount = 0; + for (final dive in exportedResult.dives) { + final tanks = dive['tanks'] as List?; + if (tanks != null) { + exportedTankCount += tanks.length; + } + } + + expect( + exportedTankCount, + equals(originalTankCount), + reason: 'Total tank count should match through round-trip', + ); + }); + + test('imports multi-tank dive and stores separate pressure data', () async { + // 1. Manually create a UDDF string for a two-tank dive + const uddfContent = ''' + + + + + + 2024-01-01T12:00:00 + 1 + + + 12.0 + + + 11.0 + + + + 10 + 10.0 + 20000000 + 19000000 + + + 20 + 20.0 + 18000000 + 17000000 + + + + + + +'''; + + // 2. Import the data into the database + final importer = createImporter(); + await importer.importFromContent(uddfContent); + + // 3. Verify the dive was created + final diveRepository = DiveRepository(); + final dives = await diveRepository.getAllDives(); + expect(dives, hasLength(1)); + final diveId = dives.first.id; + + // 4. Query the tank pressure data from the repository + final tankPressureRepository = TankPressureRepository(); + final pressuresByTank = await tankPressureRepository + .getTankPressuresForDive(diveId); + + // 5. Assert that pressure data for two separate tanks was stored + expect( + pressuresByTank.keys, + hasLength(2), + reason: 'Should have pressure data for two tanks.', + ); + + final pressureSeries = pressuresByTank.values.toList() + ..sort((a, b) => a.first.pressure.compareTo(b.first.pressure)); + final pressuresLower = pressureSeries[0]; + final pressuresHigher = pressureSeries[1]; + + expect(pressuresLower, hasLength(2)); + expect(pressuresHigher, hasLength(2)); + expect(pressuresHigher.first.pressure, closeTo(200.0, 0.1)); + expect(pressuresLower.first.pressure, closeTo(190.0, 0.1)); + }); + + test( + 'imports multi-tank dive without tank IDs and stores separate pressure data', + () async { + // Test case for UDDF files where tankdata elements don't have id attributes + // but waypoints reference tanks as "T1", "T2", etc. (like Perdix AI exports) + const uddfContent = ''' + + + + + + 235 + 2025-09-01T14:18:24Z + + + 20049962 + 12879411 + + + 21952916 + 14244574 + + + 0 + 0 + + + 0 + 0 + + + + 1 + 0 + 20049962 + 21952916 + + + 3 + 10 + 19939646 + 21939126 + + + + + + +'''; + + // 2. Import the data into the database + final importer = createImporter(); + await importer.importFromContent(uddfContent); + + // 3. Verify the dive was created + final diveRepository = DiveRepository(); + final dives = await diveRepository.getAllDives(); + expect(dives, hasLength(1)); + final diveId = dives.first.id; + + // 4. Query the tank pressure data from the repository + final tankPressureRepository = TankPressureRepository(); + final pressuresByTank = await tankPressureRepository + .getTankPressuresForDive(diveId); + + // 5. Assert that pressure data for two separate tanks was stored + // (tanks 3-4 have zero pressures and should be filtered out) + expect( + pressuresByTank.keys, + hasLength(2), + reason: + 'Should have pressure data for two tanks with non-zero pressures.', + ); + + final pressureSeries = pressuresByTank.values.toList() + ..sort((a, b) => a.first.pressure.compareTo(b.first.pressure)); + final pressuresT1 = pressureSeries[0]; + final pressuresT2 = pressureSeries[1]; + + expect(pressuresT1, hasLength(2)); + expect(pressuresT2, hasLength(2)); + // First waypoint pressures + expect(pressuresT1.first.pressure, closeTo(200.5, 0.1)); + expect(pressuresT2.first.pressure, closeTo(219.5, 0.1)); + // Second waypoint pressures + expect(pressuresT1.last.pressure, closeTo(199.4, 0.1)); + expect(pressuresT2.last.pressure, closeTo(219.4, 0.1)); + }, + ); + }); +} From 270b733067b180b96f1e3d5e586736f439972651 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:11:44 -0400 Subject: [PATCH 053/122] feat(db): v162 diver_settings.place_name_language (#1187) --- lib/core/constants/place_name_language.dart | 31 ++++++++++ lib/core/database/database.dart | 29 ++++++++- .../constants/place_name_language_test.dart | 35 +++++++++++ ...gration_v162_place_name_language_test.dart | 62 +++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 lib/core/constants/place_name_language.dart create mode 100644 test/core/constants/place_name_language_test.dart create mode 100644 test/core/database/migration_v162_place_name_language_test.dart diff --git a/lib/core/constants/place_name_language.dart b/lib/core/constants/place_name_language.dart new file mode 100644 index 0000000000..7a6b0d7057 --- /dev/null +++ b/lib/core/constants/place_name_language.dart @@ -0,0 +1,31 @@ +/// The language reverse-geocoded place names are stored in. +/// +/// A synced per-diver setting (issue #1187). Stored as the ISO 639-1 code, +/// never as a display name. English is the default because every row +/// written before the setting existed was geocoded in English (issue #214), +/// and mixing languages within one logbook splits a country across two +/// statistics buckets. There is deliberately no "follow app language" +/// value: the app language can be `system`, which resolves per device. +abstract final class PlaceNameLanguage { + static const String defaultCode = 'en'; + + /// The app's own languages, in the order the language picker lists them. + static const List supportedCodes = [ + 'en', + 'es', + 'fr', + 'de', + 'it', + 'nl', + 'pt', + 'hu', + 'ar', + 'he', + 'zh', + ]; + + /// A supported code, or [defaultCode] for anything else. A synced peer on a + /// newer build could send a code this build does not know. + static String normalize(String? code) => + code != null && supportedCodes.contains(code) ? code : defaultCode; +} diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 92124f1a74..d05b9225ad 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1663,6 +1663,9 @@ class DiverSettings extends Table { boolean().withDefault(const Constant(false))(); // Locale (language preference: 'system', 'en', 'es', 'fr', etc.) TextColumn get locale => text().withDefault(const Constant('system'))(); + // Language for reverse-geocoded place names, ISO 639-1 (issue #1187, v162) + TextColumn get placeNameLanguage => + text().withDefault(const Constant('en'))(); // Defaults TextColumn get defaultDiveType => text().withDefault(const Constant('recreational'))(); @@ -3165,7 +3168,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 = 162; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3450,6 +3453,9 @@ 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, + // v162: diver_settings.place_name_language, the synced language used for + // reverse-geocoded country/region/town/body of water (issue #1187). + 162, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4920,6 +4926,22 @@ class AppDatabase extends _$AppDatabase { } } + /// v162: place_name_language on diver_settings (issue #1187). Defaults to + /// 'en', the language every pre-v162 row was geocoded in (issue #214). + Future _assertPlaceNameLanguageColumn() 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('place_name_language')) { + await customStatement( + "ALTER TABLE diver_settings ADD COLUMN place_name_language TEXT " + "NOT NULL DEFAULT 'en'", + ); + } + } + /// 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 +8557,11 @@ class AppDatabase extends _$AppDatabase { await _assertO2CellMvDefaultColumn(); } if (from < 161) await reportProgress(); + // v162: place_name_language on diver_settings (issue #1187). + if (from < 162) { + await _assertPlaceNameLanguageColumn(); + } + if (from < 162) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys diff --git a/test/core/constants/place_name_language_test.dart b/test/core/constants/place_name_language_test.dart new file mode 100644 index 0000000000..3eed73450e --- /dev/null +++ b/test/core/constants/place_name_language_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/place_name_language.dart'; + +void main() { + test('English is the default', () { + expect(PlaceNameLanguage.defaultCode, 'en'); + }); + + test('every app language except system is supported', () { + expect(PlaceNameLanguage.supportedCodes, [ + 'en', + 'es', + 'fr', + 'de', + 'it', + 'nl', + 'pt', + 'hu', + 'ar', + 'he', + 'zh', + ]); + }); + + test('normalize keeps a supported code', () { + expect(PlaceNameLanguage.normalize('de'), 'de'); + }); + + test('normalize falls back to English for unknown, null or blank', () { + expect(PlaceNameLanguage.normalize('xx'), 'en'); + expect(PlaceNameLanguage.normalize(null), 'en'); + expect(PlaceNameLanguage.normalize(''), 'en'); + expect(PlaceNameLanguage.normalize('system'), 'en'); + }); +} diff --git a/test/core/database/migration_v162_place_name_language_test.dart b/test/core/database/migration_v162_place_name_language_test.dart new file mode 100644 index 0000000000..369082cca4 --- /dev/null +++ b/test/core/database/migration_v162_place_name_language_test.dart @@ -0,0 +1,62 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v162 shape: a diver_settings table without the place name +/// language column, stamped at v161 so the 161->162 upgrade 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("v162 adds place_name_language defaulting to 'en'", () 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('place_name_language')); + + final row = await db + .customSelect('SELECT place_name_language FROM diver_settings') + .getSingle(); + expect(row.read('place_name_language'), 'en'); + }); + + test('fresh databases get the place_name_language 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('place_name_language')); + }); + + 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('v162 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(162)); + expect(AppDatabase.migrationVersions, contains(162)); + }); +} From 90c3e44e5eaa512017066a9a371ba948c0bd80c2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:16:15 -0400 Subject: [PATCH 054/122] refactor(media): seed the Set-time dialog from one clamped helper Both call sites derived the dialog's starting offset with the same expression and could hand it a value inside the dive window but outside the profile (a surface shot at -1:30, a debrief shot past the last sample), relying on the dialog's internal clamp. setTimeSeedFor owns the rule now: pin, else in-window automatic position, else 0, clamped to the profile length, with unit tests for each branch. --- .../presentation/helpers/set_time_seed.dart | 21 +++++ .../presentation/pages/media_viewer_page.dart | 14 ++-- .../widgets/media_info_panel.dart | 8 +- .../helpers/set_time_seed_test.dart | 82 +++++++++++++++++++ 4 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 lib/features/media/presentation/helpers/set_time_seed.dart create mode 100644 test/features/media/presentation/helpers/set_time_seed_test.dart diff --git a/lib/features/media/presentation/helpers/set_time_seed.dart b/lib/features/media/presentation/helpers/set_time_seed.dart new file mode 100644 index 0000000000..be830816cf --- /dev/null +++ b/lib/features/media/presentation/helpers/set_time_seed.dart @@ -0,0 +1,21 @@ +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +/// The offset the Set-time dialog opens on for [item] (issue #1090): the +/// diver's pin if there is one, else the automatic position if it is inside +/// the dive window, else the dive start. +/// +/// The result is always inside `0..profileLengthSeconds`, the dialog's +/// range. The window includes the pre/post-dive buffers, so an automatic +/// position can sit at -1:30 or past the last sample; the nearest moment +/// the diver can actually pin is the start or the end, and choosing it here +/// keeps that rule at the one place both call sites share instead of in a +/// clamp inside the dialog the caller cannot see. +int setTimeSeedFor(MediaItem item, {required int profileLengthSeconds}) { + final enrichment = item.enrichment; + final positioned = + enrichment?.isWithinDiveWindow(profileLengthSeconds) ?? false; + final seed = + item.manualElapsedSeconds ?? + (positioned ? enrichment!.elapsedSeconds! : 0); + return seed.clamp(0, profileLengthSeconds); +} diff --git a/lib/features/media/presentation/pages/media_viewer_page.dart b/lib/features/media/presentation/pages/media_viewer_page.dart index b14f5486d4..7182e7c303 100644 --- a/lib/features/media/presentation/pages/media_viewer_page.dart +++ b/lib/features/media/presentation/pages/media_viewer_page.dart @@ -25,6 +25,7 @@ import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; import 'package:submersion/features/media/presentation/helpers/elapsed_time_format.dart'; import 'package:submersion/features/media/presentation/helpers/media_share_helper.dart'; +import 'package:submersion/features/media/presentation/helpers/set_time_seed.dart'; import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; @@ -154,18 +155,13 @@ class _MediaViewerPageState extends ConsumerState { List profile, AppSettings settings, ) async { - final enrichment = item.enrichment; - final positioned = - enrichment?.isWithinDiveWindow( - MediaDiveWindow.profileLengthSeconds(profile), - ) ?? - false; final choice = await showSetMediaTimeDialog( context, profile: profile, - initialElapsedSeconds: - item.manualElapsedSeconds ?? - (positioned ? enrichment!.elapsedSeconds! : 0), + initialElapsedSeconds: setTimeSeedFor( + item, + profileLengthSeconds: MediaDiveWindow.profileLengthSeconds(profile), + ), isPinned: item.manualElapsedSeconds != null, settings: settings, ); diff --git a/lib/features/media/presentation/widgets/media_info_panel.dart b/lib/features/media/presentation/widgets/media_info_panel.dart index 44c600d038..b0627a51e9 100644 --- a/lib/features/media/presentation/widgets/media_info_panel.dart +++ b/lib/features/media/presentation/widgets/media_info_panel.dart @@ -16,6 +16,7 @@ import 'package:submersion/features/media/domain/value_objects/media_source_data import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; import 'package:submersion/features/media/presentation/helpers/elapsed_time_format.dart'; import 'package:submersion/features/media/presentation/helpers/media_link_replacer.dart'; +import 'package:submersion/features/media/presentation/helpers/set_time_seed.dart'; import 'package:submersion/features/media/presentation/providers/media_provenance_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_serving_providers.dart'; @@ -159,9 +160,10 @@ class _FileSection extends ConsumerWidget { _SetTimeButton( item: item, profile: profile, - initialElapsedSeconds: - item.manualElapsedSeconds ?? - (positioned ? enrichment!.elapsedSeconds! : 0), + initialElapsedSeconds: setTimeSeedFor( + item, + profileLengthSeconds: profileLength, + ), ), ], children: [ diff --git a/test/features/media/presentation/helpers/set_time_seed_test.dart b/test/features/media/presentation/helpers/set_time_seed_test.dart new file mode 100644 index 0000000000..923746e55a --- /dev/null +++ b/test/features/media/presentation/helpers/set_time_seed_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/presentation/helpers/set_time_seed.dart'; + +/// The Set-time dialog opens on a seed both call sites (viewer chip, info +/// panel action) derive the same way: the pin if there is one, else the +/// automatic position if it is inside the dive window, else the start. +/// The seed is always inside the dialog's range, so a surface shot at +/// -1:30 opens at 0:00 by this rule rather than by a clamp the caller +/// cannot see. +MediaItem _item({int? manualElapsedSeconds, MediaEnrichment? enrichment}) { + final now = DateTime.utc(2026, 1, 1); + return MediaItem( + id: 'm1', + diveId: 'd1', + mediaType: MediaType.photo, + takenAt: now, + manualElapsedSeconds: manualElapsedSeconds, + createdAt: now, + updatedAt: now, + enrichment: enrichment, + ); +} + +MediaEnrichment _at( + int elapsedSeconds, { + MatchConfidence confidence = MatchConfidence.exact, +}) => MediaEnrichment( + id: 'e1', + mediaId: 'm1', + diveId: 'd1', + elapsedSeconds: elapsedSeconds, + depthMeters: 10, + matchConfidence: confidence, + createdAt: DateTime.utc(2026, 1, 1), +); + +void main() { + const length = 1800; + + test('a pin wins over the automatic position', () { + final item = _item(manualElapsedSeconds: 900, enrichment: _at(600)); + expect(setTimeSeedFor(item, profileLengthSeconds: length), 900); + }); + + test('an automatic position inside the profile is used as is', () { + expect( + setTimeSeedFor(_item(enrichment: _at(600)), profileLengthSeconds: length), + 600, + ); + }); + + test('no enrichment opens at the start', () { + expect(setTimeSeedFor(_item(), profileLengthSeconds: length), 0); + }); + + test('a position outside the dive window opens at the start', () { + final item = _item( + enrichment: _at(1879 * 60, confidence: MatchConfidence.estimated), + ); + expect(setTimeSeedFor(item, profileLengthSeconds: length), 0); + }); + + test('a surface shot in the pre-dive buffer opens at the start', () { + final item = _item( + enrichment: _at(-90, confidence: MatchConfidence.estimated), + ); + expect(setTimeSeedFor(item, profileLengthSeconds: length), 0); + }); + + test('a debrief shot in the post-dive buffer opens at the end', () { + final item = _item( + enrichment: _at(length + 300, confidence: MatchConfidence.estimated), + ); + expect(setTimeSeedFor(item, profileLengthSeconds: length), length); + }); + + test('a pin past a since-shortened profile opens at the end', () { + final item = _item(manualElapsedSeconds: length + 60); + expect(setTimeSeedFor(item, profileLengthSeconds: length), length); + }); +} From b058c8babc0510c3c1f1670d97bba233baee2f8d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:16:53 -0400 Subject: [PATCH 055/122] feat(settings): synced place name language preference (#1187) --- .../services/sync/sync_data_serializer.dart | 3 + .../diver_settings_repository.dart | 4 + .../providers/settings_providers.dart | 20 +++++ .../sync_diver_settings_fallback_test.dart | 32 ++++++++ ...ver_settings_place_name_language_test.dart | 74 +++++++++++++++++++ .../pages/settings_page_shared_data_test.dart | 3 + .../pages/settings_page_test.dart | 3 + .../presentation/pages/records_page_test.dart | 3 + test/helpers/mock_providers.dart | 3 + 9 files changed, 145 insertions(+) create mode 100644 test/features/settings/data/repositories/diver_settings_place_name_language_test.dart diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index a942e36284..0493675022 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -5663,6 +5663,9 @@ class SyncDataSerializer { // v161: seed it so payloads predating the column hydrate instead of // throwing in DiverSetting.fromJson. 'defaultShowO2CellMv': false, + // v162: seed it so payloads predating the column hydrate instead of + // throwing in DiverSetting.fromJson (issue #1187). + 'placeNameLanguage': 'en', // Dive profile default-visible metrics. Non-nullable bool added in v91; // seed it so payloads predating the column hydrate instead of throwing in // DiverSetting.fromJson. diff --git a/lib/features/settings/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index d8ee465a97..026bfe5459 100644 --- a/lib/features/settings/data/repositories/diver_settings_repository.dart +++ b/lib/features/settings/data/repositories/diver_settings_repository.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; +import 'package:submersion/core/constants/place_name_language.dart'; import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; @@ -99,6 +100,7 @@ class DiverSettingsRepository { accentSectionHeaders: Value(s.accentSectionHeaders), accentListIcons: Value(s.accentListIcons), locale: Value(s.locale), + placeNameLanguage: Value(s.placeNameLanguage), defaultDiveType: Value(s.defaultDiveType), defaultTankVolume: Value(s.defaultTankVolume), defaultStartPressure: Value(s.defaultStartPressure), @@ -259,6 +261,7 @@ class DiverSettingsRepository { accentSectionHeaders: Value(settings.accentSectionHeaders), accentListIcons: Value(settings.accentListIcons), locale: Value(settings.locale), + placeNameLanguage: Value(settings.placeNameLanguage), defaultDiveType: Value(settings.defaultDiveType), defaultTankVolume: Value(settings.defaultTankVolume), defaultStartPressure: Value(settings.defaultStartPressure), @@ -463,6 +466,7 @@ class DiverSettingsRepository { accentSectionHeaders: row.accentSectionHeaders, accentListIcons: row.accentListIcons, locale: row.locale, + placeNameLanguage: PlaceNameLanguage.normalize(row.placeNameLanguage), defaultDiveType: row.defaultDiveType, defaultTankVolume: row.defaultTankVolume, defaultStartPressure: row.defaultStartPressure, diff --git a/lib/features/settings/presentation/providers/settings_providers.dart b/lib/features/settings/presentation/providers/settings_providers.dart index a0b2d2f5d5..7aad40fedc 100644 --- a/lib/features/settings/presentation/providers/settings_providers.dart +++ b/lib/features/settings/presentation/providers/settings_providers.dart @@ -4,6 +4,7 @@ import 'package:submersion/core/constants/card_color.dart'; import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/constants/map_style.dart'; +import 'package:submersion/core/constants/place_name_language.dart'; import 'package:submersion/core/domain/visibility/visibility_scale.dart'; import 'package:submersion/core/utils/coordinates/coordinate_format.dart'; import 'package:submersion/core/utils/log_failure.dart'; @@ -179,6 +180,10 @@ class AppSettings { /// Color accents: tint leading icons in lists and settings pages. final bool accentListIcons; final String locale; + + /// ISO 639-1 code for reverse-geocoded place names (issue #1187). Synced + /// with the diver so every device stores the same spelling. + final String placeNameLanguage; final String defaultDiveType; final double defaultTankVolume; final int defaultStartPressure; @@ -486,6 +491,7 @@ class AppSettings { this.accentSectionHeaders = false, this.accentListIcons = false, this.locale = 'system', + this.placeNameLanguage = PlaceNameLanguage.defaultCode, this.defaultDiveType = 'recreational', this.defaultTankVolume = 12.0, this.defaultStartPressure = 200, @@ -646,6 +652,7 @@ class AppSettings { bool? accentSectionHeaders, bool? accentListIcons, String? locale, + String? placeNameLanguage, String? defaultDiveType, double? defaultTankVolume, int? defaultStartPressure, @@ -775,6 +782,7 @@ class AppSettings { accentSectionHeaders: accentSectionHeaders ?? this.accentSectionHeaders, accentListIcons: accentListIcons ?? this.accentListIcons, locale: locale ?? this.locale, + placeNameLanguage: placeNameLanguage ?? this.placeNameLanguage, defaultDiveType: defaultDiveType ?? this.defaultDiveType, defaultTankVolume: defaultTankVolume ?? this.defaultTankVolume, defaultStartPressure: defaultStartPressure ?? this.defaultStartPressure, @@ -1355,6 +1363,13 @@ class SettingsNotifier extends StateNotifier { await _saveSettings(); } + Future setPlaceNameLanguage(String code) async { + state = state.copyWith( + placeNameLanguage: PlaceNameLanguage.normalize(code), + ); + await _saveSettings(); + } + Future setDefaultDiveType(String diveType) async { state = state.copyWith(defaultDiveType: diveType); await _saveSettings(); @@ -2002,6 +2017,11 @@ final localeProvider = Provider((ref) { return ref.watch(settingsProvider.select((s) => s.locale)); }); +/// The language new reverse-geocode results are stored in (issue #1187). +final placeNameLanguageProvider = Provider((ref) { + return ref.watch(settingsProvider.select((s) => s.placeNameLanguage)); +}); + /// Color accent toggles. Narrow selects so each surface rebuilds only when /// its own toggle changes, not on every settings mutation -- the navigation /// scaffold wraps every page, so a broad watch would rebuild the whole shell. diff --git a/test/core/services/sync/sync_diver_settings_fallback_test.dart b/test/core/services/sync/sync_diver_settings_fallback_test.dart index 787b4626ce..01a337d25b 100644 --- a/test/core/services/sync/sync_diver_settings_fallback_test.dart +++ b/test/core/services/sync/sync_diver_settings_fallback_test.dart @@ -314,4 +314,36 @@ void main() { expect(row.defaultShowO2CellMv, isFalse); }, ); + + test( + 'applies a pre-v162 diver_settings payload missing placeNameLanguage', + () async { + await db.customStatement('PRAGMA foreign_keys = OFF'); + + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diverSettings) + .insert( + DiverSettingsCompanion.insert( + id: 'ds-162', + diverId: 'diver-1', + createdAt: now, + updatedAt: now, + ), + ); + final exported = await serializer.fetchRecord('diverSettings', 'ds-162'); + final legacy = Map.from(exported!) + ..remove('placeNameLanguage'); + await (db.delete( + db.diverSettings, + )..where((t) => t.id.equals('ds-162'))).go(); + + await serializer.upsertRecord('diverSettings', legacy); + + final row = await (db.select( + db.diverSettings, + )..where((t) => t.id.equals('ds-162'))).getSingle(); + expect(row.placeNameLanguage, 'en'); + }, + ); } diff --git a/test/features/settings/data/repositories/diver_settings_place_name_language_test.dart b/test/features/settings/data/repositories/diver_settings_place_name_language_test.dart new file mode 100644 index 0000000000..0209fb7b0a --- /dev/null +++ b/test/features/settings/data/repositories/diver_settings_place_name_language_test.dart @@ -0,0 +1,74 @@ +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('AppSettings.placeNameLanguage', () { + test('defaults to English so existing logbooks keep grouping', () { + const settings = AppSettings(); + expect(settings.placeNameLanguage, 'en'); + }); + + test('copyWith carries the language', () { + const settings = AppSettings(); + final updated = settings.copyWith(placeNameLanguage: 'de'); + expect(updated.placeNameLanguage, 'de'); + expect(updated.depthUnit, settings.depthUnit); + }); + }); + + group('DiverSettingsRepository place name language 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 to English', () async { + await repository.createSettingsForDiver('d1'); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded!.placeNameLanguage, 'en'); + }); + + test('round-trips a supported code', () async { + await repository.createSettingsForDiver('d1'); + await repository.updateSettingsForDiver( + 'd1', + const AppSettings(placeNameLanguage: 'de'), + ); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded!.placeNameLanguage, 'de'); + }); + + test('an unknown stored code loads as English', () async { + await repository.createSettingsForDiver('d1'); + await db.customStatement( + "UPDATE diver_settings SET place_name_language = 'xx' " + "WHERE diver_id = 'd1'", + ); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded!.placeNameLanguage, 'en'); + }); + }); +} 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..14e5499a81 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 @@ -286,6 +286,9 @@ class _MockSettingsNotifier extends StateNotifier Future setLocale(String locale) async => state = state.copyWith(locale: locale); @override + Future setPlaceNameLanguage(String code) async => + state = state.copyWith(placeNameLanguage: code); + @override Future setDefaultDiveType(String diveType) async => state = state.copyWith(defaultDiveType: diveType); @override diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index 879c192902..2915221b41 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -157,6 +157,9 @@ class _MockSettingsNotifier extends StateNotifier Future setLocale(String locale) async => state = state.copyWith(locale: locale); @override + Future setPlaceNameLanguage(String code) async => + state = state.copyWith(placeNameLanguage: code); + @override Future setDefaultDiveType(String diveType) async => state = state.copyWith(defaultDiveType: diveType); @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..b358384158 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -139,6 +139,9 @@ class _MockSettingsNotifier extends StateNotifier Future setLocale(String locale) async => state = state.copyWith(locale: locale); @override + Future setPlaceNameLanguage(String code) async => + state = state.copyWith(placeNameLanguage: code); + @override Future setDefaultDiveType(String diveType) async => state = state.copyWith(defaultDiveType: diveType); @override diff --git a/test/helpers/mock_providers.dart b/test/helpers/mock_providers.dart index 269b46ce5c..38bf952b9f 100644 --- a/test/helpers/mock_providers.dart +++ b/test/helpers/mock_providers.dart @@ -114,6 +114,9 @@ class MockSettingsNotifier extends StateNotifier Future setLocale(String locale) async => state = state.copyWith(locale: locale); @override + Future setPlaceNameLanguage(String code) async => + state = state.copyWith(placeNameLanguage: code); + @override Future setDefaultDiveType(String diveType) async => state = state.copyWith(defaultDiveType: diveType); @override From 8bc2afe2fb0e058aa7ca06a8b502a4f465b2d23b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:19:39 -0400 Subject: [PATCH 056/122] fix(dive_log): stop estimating tank pressure on gauge dives (#731) A dive switched to gauge mode still grew a "Tank 1 (Air) (est.)" line on the profile chart. Gauge mode already suppresses gas and decompression analysis (#569), but estimatedTankPressuresProvider feeds the chart on its own path and had no dive-mode check, so it kept synthesizing a linear start-to-end trace from the tank row gauge dives deliberately retain. The "(Air)" half of the label is the GasMix default rather than a reading. Gate the provider on dive.isGauge: real transmitter samples still plot, only the fabricated series goes away. Add defaultShowEstimatedTankPressure so estimates can be turned off globally, which the reporter also asked for. It defaults to true, so nothing changes for existing divers, and follows the defaultShowO2CellMv path: schema column, settings provider and repository, and a switch under Default visible metrics. Both gates live in the provider rather than the chart, so when they fire the series does not exist at all: no legend chip, no tooltip row, no chart-options entry. The settings read is registered before the provider's first await. Schema claims v163; v162 is already claimed on main by #1090. The column follows the house pattern with a PRAGMA-guarded helper called from both the onUpgrade rung and the beforeOpen backstop. minimumCompatibleSchemaVersion stays at 160, since a new defaulted column is additive. Localized across all 11 locales. --- lib/core/database/database.dart | 41 +++- .../services/sync/sync_data_serializer.dart | 2 + .../providers/dive_providers.dart | 17 ++ .../diver_settings_repository.dart | 7 + .../pages/default_visible_metrics_page.dart | 7 + .../providers/settings_providers.dart | 15 ++ lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 4 + lib/l10n/arb/app_localizations_de.dart | 4 + lib/l10n/arb/app_localizations_en.dart | 4 + lib/l10n/arb/app_localizations_es.dart | 4 + lib/l10n/arb/app_localizations_fr.dart | 4 + lib/l10n/arb/app_localizations_he.dart | 4 + lib/l10n/arb/app_localizations_hu.dart | 4 + lib/l10n/arb/app_localizations_it.dart | 4 + lib/l10n/arb/app_localizations_nl.dart | 4 + lib/l10n/arb/app_localizations_pt.dart | 4 + lib/l10n/arb/app_localizations_zh.dart | 3 + lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + ..._estimated_tank_pressure_default_test.dart | 127 +++++++++++ .../sync_diver_settings_fallback_test.dart | 39 ++++ ...stimated_tank_pressures_provider_test.dart | 197 ++++++++++++++++++ ...pository_estimated_tank_pressure_test.dart | 54 +++++ .../default_visible_metrics_page_test.dart | 46 ++++ .../pages/settings_page_shared_data_test.dart | 3 + .../pages/settings_page_test.dart | 3 + .../presentation/pages/records_page_test.dart | 3 + test/helpers/mock_providers.dart | 3 + 38 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 test/core/database/migration_v163_estimated_tank_pressure_default_test.dart create mode 100644 test/features/settings/data/repositories/diver_settings_repository_estimated_tank_pressure_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 92124f1a74..f0e81a6f7f 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1810,6 +1810,11 @@ class DiverSettings extends Table { // v161: default visibility for the per-cell O2 mV traces (issue #1235). BoolColumn get defaultShowO2CellMv => boolean().withDefault(const Constant(false))(); + // v163: whether synthesized ("(est.)") tank pressure lines are drawn on the + // profile chart at all (issue #731). Defaults to true, preserving the + // behavior estimates shipped with. + BoolColumn get defaultShowEstimatedTankPressure => + boolean().withDefault(const Constant(true))(); // Drift column declarations are codegen inputs shadowed by the generated // table at runtime, so this line is never executed (every sibling column // getter is likewise uncovered). The default is verified via the migration @@ -3165,7 +3170,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 +3455,10 @@ 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.default_show_estimated_tank_pressure, the switch + // that suppresses synthesized "(est.)" tank pressure lines on the profile + // chart (issue #731). v162 was claimed first on main by #1090. + 163, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4920,6 +4929,25 @@ class AppDatabase extends _$AppDatabase { } } + /// v163: default_show_estimated_tank_pressure on diver_settings (issue + /// #731). Synthesized "(est.)" pressure lines previously had no off switch. + /// Defaults to 1 so existing databases keep drawing them. + Future _assertEstimatedTankPressureDefaultColumn() 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('default_show_estimated_tank_pressure')) { + await customStatement( + 'ALTER TABLE diver_settings ADD COLUMN ' + 'default_show_estimated_tank_pressure ' + 'INTEGER NOT NULL DEFAULT 1 ' + 'CHECK (default_show_estimated_tank_pressure 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 +8563,12 @@ class AppDatabase extends _$AppDatabase { await _assertO2CellMvDefaultColumn(); } if (from < 161) await reportProgress(); + // v163: default_show_estimated_tank_pressure on diver_settings + // (issue #731). + if (from < 163) { + await _assertEstimatedTankPressureDefaultColumn(); + } + if (from < 163) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -8729,6 +8763,11 @@ class AppDatabase extends _$AppDatabase { // (issue #1235; same parallel-branch version-collision self-heal). await _assertO2CellMvDefaultColumn(); + // v163 backstop: re-assert + // diver_settings.default_show_estimated_tank_pressure (issue #731; + // same parallel-branch version-collision self-heal). + await _assertEstimatedTankPressureDefaultColumn(); + // v145 backstop: re-assert the gps_tracks provenance and trim columns. await _assertGpsTrackColumns(); diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index a942e36284..cb8e15b520 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -5663,6 +5663,8 @@ class SyncDataSerializer { // v161: seed it so payloads predating the column hydrate instead of // throwing in DiverSetting.fromJson. 'defaultShowO2CellMv': false, + // v163: seed it so payloads predating the column hydrate instead of + // throwing in DiverSetting.fromJson (issue #731). // Dive profile default-visible metrics. Non-nullable bool added in v91; // seed it so payloads predating the column hydrate instead of throwing in // DiverSetting.fromJson. diff --git a/lib/features/dive_log/presentation/providers/dive_providers.dart b/lib/features/dive_log/presentation/providers/dive_providers.dart index f261fdbfce..b9071fb75e 100644 --- a/lib/features/dive_log/presentation/providers/dive_providers.dart +++ b/lib/features/dive_log/presentation/providers/dive_providers.dart @@ -1054,11 +1054,28 @@ final estimatedTankPressuresProvider = final realFuture = ref.watch(tankPressuresProvider(diveId).future); final diveFuture = ref.watch(diveProvider(diveId).future); final switchesFuture = ref.watch(gasSwitchesProvider(diveId).future); + // Read synchronously, before the first await, so the dependency is + // registered while the provider is certainly still alive. + final showEstimates = ref.watch( + settingsProvider.select((s) => s.defaultShowEstimatedTankPressure), + ); final real = await realFuture; final dive = await diveFuture; if (dive == null) { return EstimatedTankPressures(real, const {}); } + // A gauge (bottom-timer) dive models no gas at all, so a synthesized + // pressure trace would be fabricated rather than measured (issue #731). + // Real transmitter samples, if the dive has any, still pass through. + if (dive.isGauge) { + return EstimatedTankPressures(real, const {}); + } + // The diver can switch estimates off entirely (issue #731). Gating here + // rather than at the chart means the series never exists, so no legend + // chip, tooltip row, or "(est.)" label survives anywhere. + if (!showEstimates) { + return EstimatedTankPressures(real, const {}); + } final switches = await switchesFuture; return synthesizeEstimatedTankPressures( existing: real, diff --git a/lib/features/settings/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index d8ee465a97..269ceacb4a 100644 --- a/lib/features/settings/data/repositories/diver_settings_repository.dart +++ b/lib/features/settings/data/repositories/diver_settings_repository.dart @@ -178,6 +178,9 @@ class DiverSettingsRepository { defaultShowPhotoMarkers: Value(s.defaultShowPhotoMarkers), defaultShowGasTimeline: Value(s.defaultShowGasTimeline), defaultShowO2CellMv: Value(s.defaultShowO2CellMv), + defaultShowEstimatedTankPressure: Value( + s.defaultShowEstimatedTankPressure, + ), defaultShowAscentRateLine: Value(s.defaultShowAscentRateLine), notificationsEnabled: Value(s.notificationsEnabled), serviceReminderDays: Value( @@ -342,6 +345,9 @@ class DiverSettingsRepository { defaultShowPhotoMarkers: Value(settings.defaultShowPhotoMarkers), defaultShowGasTimeline: Value(settings.defaultShowGasTimeline), defaultShowO2CellMv: Value(settings.defaultShowO2CellMv), + defaultShowEstimatedTankPressure: Value( + settings.defaultShowEstimatedTankPressure, + ), defaultShowAscentRateLine: Value(settings.defaultShowAscentRateLine), notificationsEnabled: Value(settings.notificationsEnabled), serviceReminderDays: Value( @@ -544,6 +550,7 @@ class DiverSettingsRepository { defaultShowPhotoMarkers: row.defaultShowPhotoMarkers, defaultShowGasTimeline: row.defaultShowGasTimeline, defaultShowO2CellMv: row.defaultShowO2CellMv, + defaultShowEstimatedTankPressure: row.defaultShowEstimatedTankPressure, defaultShowAscentRateLine: row.defaultShowAscentRateLine, notificationsEnabled: row.notificationsEnabled, serviceReminderDays: _parseReminderDays(row.serviceReminderDays), diff --git a/lib/features/settings/presentation/pages/default_visible_metrics_page.dart b/lib/features/settings/presentation/pages/default_visible_metrics_page.dart index d82e838776..0029f6c50d 100644 --- a/lib/features/settings/presentation/pages/default_visible_metrics_page.dart +++ b/lib/features/settings/presentation/pages/default_visible_metrics_page.dart @@ -38,6 +38,13 @@ class DefaultVisibleMetricsPage extends ConsumerWidget { value: settings.defaultShowPressure, onChanged: notifier.setDefaultShowPressure, ), + SwitchListTile( + title: Text( + context.l10n.settings_appearance_metric_estimatedTankPressure, + ), + value: settings.defaultShowEstimatedTankPressure, + onChanged: notifier.setDefaultShowEstimatedTankPressure, + ), SwitchListTile( title: Text(context.l10n.settings_appearance_metric_heartRate), value: settings.defaultShowHeartRate, diff --git a/lib/features/settings/presentation/providers/settings_providers.dart b/lib/features/settings/presentation/providers/settings_providers.dart index a0b2d2f5d5..fc54541ffc 100644 --- a/lib/features/settings/presentation/providers/settings_providers.dart +++ b/lib/features/settings/presentation/providers/settings_providers.dart @@ -394,6 +394,11 @@ class AppSettings { /// Default visibility for the per-cell O2 mV traces on the dive profile final bool defaultShowO2CellMv; + /// Whether synthesized ("(est.)") tank pressure lines are drawn on the dive + /// profile at all. Off means the estimate is never built, so no legend chip, + /// tooltip row, or chart-options entry appears for it (issue #731). + final bool defaultShowEstimatedTankPressure; + /// Default visibility for the separate ascent-rate magnitude line on the /// dive profile (distinct from [showAscentRateColors], which tints the depth /// line by velocity band). @@ -561,6 +566,7 @@ class AppSettings { this.defaultShowPhotoMarkers = true, this.defaultShowGasTimeline = false, this.defaultShowO2CellMv = false, + this.defaultShowEstimatedTankPressure = true, this.defaultShowAscentRateLine = false, // Notification defaults this.notificationsEnabled = true, @@ -721,6 +727,7 @@ class AppSettings { bool? defaultShowPhotoMarkers, bool? defaultShowGasTimeline, bool? defaultShowO2CellMv, + bool? defaultShowEstimatedTankPressure, bool? defaultShowAscentRateLine, bool? notificationsEnabled, List? serviceReminderDays, @@ -870,6 +877,9 @@ class AppSettings { defaultShowGasTimeline: defaultShowGasTimeline ?? this.defaultShowGasTimeline, defaultShowO2CellMv: defaultShowO2CellMv ?? this.defaultShowO2CellMv, + defaultShowEstimatedTankPressure: + defaultShowEstimatedTankPressure ?? + this.defaultShowEstimatedTankPressure, defaultShowAscentRateLine: defaultShowAscentRateLine ?? this.defaultShowAscentRateLine, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, @@ -1799,6 +1809,11 @@ class SettingsNotifier extends StateNotifier { await _saveSettings(); } + Future setDefaultShowEstimatedTankPressure(bool value) async { + state = state.copyWith(defaultShowEstimatedTankPressure: value); + await _saveSettings(); + } + Future setDefaultShowAscentRateLine(bool value) async { state = state.copyWith(defaultShowAscentRateLine: value); await _saveSettings(); diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..10b16f4ea2 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -4430,6 +4430,7 @@ "settings_appearance_metric_ceiling": "السقف", "settings_appearance_metric_cns": "CNS% (سمية الأكسجين)", "settings_appearance_metric_events": "الأحداث", + "settings_appearance_metric_estimatedTankPressure": "ضغط الأسطوانة المقدر", "settings_appearance_metric_gasDensity": "كثافة الغاز", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "معدل ضربات القلب", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..ba51ebd7ff 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -4430,6 +4430,7 @@ "settings_appearance_metric_ceiling": "Ceiling", "settings_appearance_metric_cns": "CNS% (O2-Toxizität)", "settings_appearance_metric_events": "Ereignisse", + "settings_appearance_metric_estimatedTankPressure": "Geschätzter Flaschendruck", "settings_appearance_metric_gasDensity": "Gasdichte", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Herzfrequenz", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..6610752de3 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -8671,6 +8671,10 @@ "settings_appearance_metric_ascentRateColors": "Ascent Rate Colors", "settings_appearance_metric_ceiling": "Ceiling", "settings_appearance_metric_events": "Events", + "settings_appearance_metric_estimatedTankPressure": "Estimated Tank Pressure", + "@settings_appearance_metric_estimatedTankPressure": { + "description": "Settings switch that turns off the synthesized (estimated) tank pressure line on the dive profile chart." + }, "settings_appearance_metric_gasDensity": "Gas Density", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Heart Rate", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..a19fcd2abc 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -4430,6 +4430,7 @@ "settings_appearance_metric_ceiling": "Techo", "settings_appearance_metric_cns": "CNS% (Toxicidad de O2)", "settings_appearance_metric_events": "Eventos", + "settings_appearance_metric_estimatedTankPressure": "Presión estimada del tanque", "settings_appearance_metric_gasDensity": "Densidad del gas", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Frecuencia cardiaca", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..49f7a05942 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -4357,6 +4357,7 @@ "settings_appearance_metric_ceiling": "Plafond", "settings_appearance_metric_cns": "CNS% (Toxicite O2)", "settings_appearance_metric_events": "Evenements", + "settings_appearance_metric_estimatedTankPressure": "Pression estimée du bloc", "settings_appearance_metric_gasDensity": "Densite du gaz", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Frequence cardiaque", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..3d52990666 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -4434,6 +4434,7 @@ "settings_appearance_metric_ceiling": "תקרה", "settings_appearance_metric_cns": "CNS% (רעילות חמצן)", "settings_appearance_metric_events": "אירועים", + "settings_appearance_metric_estimatedTankPressure": "לחץ בלון משוער", "settings_appearance_metric_gasDensity": "צפיפות גז", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "קצב לב", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..9a3fed9d93 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -4357,6 +4357,7 @@ "settings_appearance_metric_ceiling": "Plafon", "settings_appearance_metric_cns": "CNS% (O2 toxicitás)", "settings_appearance_metric_events": "Esemenyek", + "settings_appearance_metric_estimatedTankPressure": "Becsült palacknyomás", "settings_appearance_metric_gasDensity": "Gaz suruseg", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Szivfrekvencia", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..1e8818c887 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -4357,6 +4357,7 @@ "settings_appearance_metric_ceiling": "Ceiling", "settings_appearance_metric_cns": "CNS% (Tossicita O2)", "settings_appearance_metric_events": "Eventi", + "settings_appearance_metric_estimatedTankPressure": "Pressione stimata della bombola", "settings_appearance_metric_gasDensity": "Densità gas", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Frequenza cardiaca", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..9907057224 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -24502,6 +24502,12 @@ abstract class AppLocalizations { /// **'Events'** String get settings_appearance_metric_events; + /// Settings switch that turns off the synthesized (estimated) tank pressure line on the dive profile chart. + /// + /// In en, this message translates to: + /// **'Estimated Tank Pressure'** + String get settings_appearance_metric_estimatedTankPressure; + /// No description provided for @settings_appearance_metric_gasDensity. /// /// 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..067ccc0831 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -14273,6 +14273,10 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_appearance_metric_events => 'الأحداث'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'ضغط الأسطوانة المقدر'; + @override String get settings_appearance_metric_gasDensity => 'كثافة الغاز'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..ca27df842c 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -14509,6 +14509,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_appearance_metric_events => 'Ereignisse'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Geschätzter Flaschendruck'; + @override String get settings_appearance_metric_gasDensity => 'Gasdichte'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..bad80c6f7e 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -14292,6 +14292,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_appearance_metric_events => 'Events'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Estimated Tank Pressure'; + @override String get settings_appearance_metric_gasDensity => 'Gas Density'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..c3fca1f886 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -14520,6 +14520,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_appearance_metric_events => 'Eventos'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Presión estimada del tanque'; + @override String get settings_appearance_metric_gasDensity => 'Densidad del gas'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..e7bf4922ee 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -14569,6 +14569,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_appearance_metric_events => 'Evenements'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Pression estimée du bloc'; + @override String get settings_appearance_metric_gasDensity => 'Densite du gaz'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..71113836d8 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -14177,6 +14177,10 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_appearance_metric_events => 'אירועים'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'לחץ בלון משוער'; + @override String get settings_appearance_metric_gasDensity => 'צפיפות גז'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..0ad2ff2219 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -14479,6 +14479,10 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_appearance_metric_events => 'Esemenyek'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Becsült palacknyomás'; + @override String get settings_appearance_metric_gasDensity => 'Gaz suruseg'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..0c3623e6ac 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -14526,6 +14526,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_appearance_metric_events => 'Eventi'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Pressione stimata della bombola'; + @override String get settings_appearance_metric_gasDensity => 'Densità gas'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..0fa00e1e5d 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -14418,6 +14418,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_appearance_metric_events => 'Gebeurtenissen'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Geschatte flesdruk'; + @override String get settings_appearance_metric_gasDensity => 'Gasdichtheid'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..341c430f64 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -14525,6 +14525,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_appearance_metric_events => 'Eventos'; + @override + String get settings_appearance_metric_estimatedTankPressure => + 'Pressão estimada do cilindro'; + @override String get settings_appearance_metric_gasDensity => 'Densidade do Gas'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..d6f3317944 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -13844,6 +13844,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_appearance_metric_events => '事件'; + @override + String get settings_appearance_metric_estimatedTankPressure => '估算气瓶压力'; + @override String get settings_appearance_metric_gasDensity => '气体密度'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..8c4eccfa6a 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -4430,6 +4430,7 @@ "settings_appearance_metric_ceiling": "Plafond", "settings_appearance_metric_cns": "CNS% (O2-toxiciteit)", "settings_appearance_metric_events": "Gebeurtenissen", + "settings_appearance_metric_estimatedTankPressure": "Geschatte flesdruk", "settings_appearance_metric_gasDensity": "Gasdichtheid", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Hartslag", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..e24cf6b3f6 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -4430,6 +4430,7 @@ "settings_appearance_metric_ceiling": "Teto", "settings_appearance_metric_cns": "CNS% (Toxicidade de O2)", "settings_appearance_metric_events": "Eventos", + "settings_appearance_metric_estimatedTankPressure": "Pressão estimada do cilindro", "settings_appearance_metric_gasDensity": "Densidade do Gas", "settings_appearance_metric_gfPercent": "GF%", "settings_appearance_metric_heartRate": "Frequencia Cardiaca", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..642058447d 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -4586,6 +4586,7 @@ "settings_appearance_metric_ceiling": "上升限制", "settings_appearance_metric_cns": "中枢神经系统% (O2 毒性)", "settings_appearance_metric_events": "事件", + "settings_appearance_metric_estimatedTankPressure": "估算气瓶压力", "settings_appearance_metric_gasDensity": "气体密度", "settings_appearance_metric_gfPercent": "梯度因子%", "settings_appearance_metric_heartRate": "心率", diff --git a/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart b/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart new file mode 100644 index 0000000000..aee312f54b --- /dev/null +++ b/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart @@ -0,0 +1,127 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +/// Minimal pre-v163 diver_settings stamped at v161, so opening it runs the +/// 161 -> 163 rung of the ladder rather than only the beforeOpen backstop. +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, + created_at INTEGER, + updated_at INTEGER + ) + '''); + rawDb.execute( + "INSERT INTO diver_settings (id, created_at, updated_at) " + "VALUES ('ds1', 0, 0)", + ); + }, + ); +} + +/// v163 adds the switch that suppresses synthesized "(est.)" tank pressure +/// lines on the profile chart (issue #731). v162 was claimed first on main by +/// #1090. +void main() { + test('v163 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(163)); + expect(AppDatabase.migrationVersions, contains(163)); + }); + + test( + 'a fresh database has diver_settings.default_show_estimated_tank_pressure', + () 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('default_show_estimated_tank_pressure')); + }, + ); + + test('the column defaults to showing estimates', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('diver_settings')") + .get(); + final column = cols.firstWhere( + (c) => c.read('name') == 'default_show_estimated_tank_pressure', + ); + // Estimates shipped always-on, so defaulting to 1 means upgrading does not + // silently remove a line a diver was already reading. + expect(column.read('dflt_value'), '1'); + }); + + test( + 'a database stranded before v163 gains the column via beforeOpen', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute(''' + CREATE TABLE diver_settings ( + id TEXT NOT NULL PRIMARY KEY, + created_at INTEGER, + updated_at INTEGER + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + 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('default_show_estimated_tank_pressure')); + }, + ); + + test('the assert is a no-op when the table is absent', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('CREATE TABLE unrelated (id TEXT)'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + await db.customSelect('SELECT 1').get(); + }); + + test( + 'the 161 -> 163 upgrade keeps estimates on for an existing row', + () async { + final db = AppDatabase(_dbAt161()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('diver_settings')") + .get(); + expect( + cols.map((c) => c.read('name')), + contains('default_show_estimated_tank_pressure'), + ); + + // This open runs both the ladder rung and the beforeOpen backstop, so it + // also proves the assert does not try to re-add a column the migration + // just created. + final row = await db + .customSelect( + 'SELECT default_show_estimated_tank_pressure FROM diver_settings', + ) + .getSingle(); + expect(row.read('default_show_estimated_tank_pressure'), isTrue); + }, + ); +} diff --git a/test/core/services/sync/sync_diver_settings_fallback_test.dart b/test/core/services/sync/sync_diver_settings_fallback_test.dart index 787b4626ce..0f2d22ab74 100644 --- a/test/core/services/sync/sync_diver_settings_fallback_test.dart +++ b/test/core/services/sync/sync_diver_settings_fallback_test.dart @@ -314,4 +314,43 @@ void main() { expect(row.defaultShowO2CellMv, isFalse); }, ); + + test( + 'applies a pre-v163 diver_settings payload missing the estimate default', + () async { + await db.customStatement('PRAGMA foreign_keys = OFF'); + + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diverSettings) + .insert( + DiverSettingsCompanion.insert( + id: 'ds9', + diverId: 'diver-9', + createdAt: now, + updatedAt: now, + ), + ); + final exported = await serializer.fetchRecord('diverSettings', 'ds9'); + expect(exported, isNotNull); + + // A peer still on v161 exports no defaultShowEstimatedTankPressure. + // _withSchemaDefaults fills it from the column's declared default, so a + // mixed-version sync must leave the estimate ON rather than silently + // switching it off (issue #731). + final legacy = Map.from(exported!) + ..remove('defaultShowEstimatedTankPressure'); + + await (db.delete( + db.diverSettings, + )..where((t) => t.id.equals('ds9'))).go(); + + await serializer.upsertRecord('diverSettings', legacy); + + final row = await (db.select( + db.diverSettings, + )..where((t) => t.id.equals('ds9'))).getSingle(); + expect(row.defaultShowEstimatedTankPressure, isTrue); + }, + ); } diff --git a/test/features/dive_log/presentation/providers/estimated_tank_pressures_provider_test.dart b/test/features/dive_log/presentation/providers/estimated_tank_pressures_provider_test.dart index d2b2390cd1..ac0b9bc155 100644 --- a/test/features/dive_log/presentation/providers/estimated_tank_pressures_provider_test.dart +++ b/test/features/dive_log/presentation/providers/estimated_tank_pressures_provider_test.dart @@ -1,9 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_log/presentation/providers/gas_switch_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/mock_providers.dart'; void main() { test('augments real map with an estimated line for a manual tank', () async { @@ -26,6 +30,7 @@ void main() { final container = ProviderContainer( overrides: [ + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), tankPressuresProvider( 'd1', ).overrideWith((ref) async => >{}), @@ -45,4 +50,196 @@ void main() { expect(result.pressures['t1']!.first.pressure, 200); expect(result.pressures['t1']!.last.pressure, 60); }); + + test('does not estimate pressures for a gauge dive', () async { + // Issue #731: a gauge (bottom-timer) dive models no gas, so a synthesized + // pressure trace would be fabricated data rather than a measurement. + final dive = Dive( + id: 'd1', + dateTime: DateTime(2026, 1, 1), + diveMode: DiveMode.gauge, + tanks: const [ + DiveTank( + id: 't1', + gasMix: GasMix(o2: 21), + startPressure: 200, + endPressure: 60, + ), + ], + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 0), + DiveProfilePoint(timestamp: 1800, depth: 0), + ], + ); + + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + tankPressuresProvider( + 'd1', + ).overrideWith((ref) async => >{}), + diveProvider('d1').overrideWith((ref) async => dive), + gasSwitchesProvider( + 'd1', + ).overrideWith((ref) async => []), + ], + ); + addTearDown(container.dispose); + + final result = await container.read( + estimatedTankPressuresProvider('d1').future, + ); + + expect(result.estimatedTankIds, isEmpty); + expect(result.pressures, isEmpty); + }); + + test('keeps real transmitter pressures on a gauge dive', () async { + // Only the synthesized line is suppressed; measured air-integrated data + // is real and still plots. + const real = >{ + 't1': [ + TankPressurePoint(id: 'p1', tankId: 't1', timestamp: 0, pressure: 200), + TankPressurePoint( + id: 'p2', + tankId: 't1', + timestamp: 1800, + pressure: 60, + ), + ], + }; + final dive = Dive( + id: 'd1', + dateTime: DateTime(2026, 1, 1), + diveMode: DiveMode.gauge, + tanks: const [ + DiveTank( + id: 't1', + gasMix: GasMix(o2: 21), + startPressure: 200, + endPressure: 60, + ), + ], + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 0), + DiveProfilePoint(timestamp: 1800, depth: 0), + ], + ); + + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + tankPressuresProvider('d1').overrideWith((ref) async => real), + diveProvider('d1').overrideWith((ref) async => dive), + gasSwitchesProvider( + 'd1', + ).overrideWith((ref) async => []), + ], + ); + addTearDown(container.dispose); + + final result = await container.read( + estimatedTankPressuresProvider('d1').future, + ); + + expect(result.estimatedTankIds, isEmpty); + expect(result.pressures['t1'], hasLength(2)); + }); + + test( + 'does not estimate pressures when the diver turned estimates off', + () async { + // Issue #731: the estimated line had no off switch. With the preference + // off the series is never synthesized, so no legend chip, tooltip row, or + // "(est.)" label appears anywhere. + final dive = Dive( + id: 'd1', + dateTime: DateTime(2026, 1, 1), + tanks: const [ + DiveTank( + id: 't1', + gasMix: GasMix(o2: 21), + startPressure: 200, + endPressure: 60, + ), + ], + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 0), + DiveProfilePoint(timestamp: 1800, depth: 0), + ], + ); + + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith( + (ref) => MockSettingsNotifier( + const AppSettings(defaultShowEstimatedTankPressure: false), + ), + ), + tankPressuresProvider( + 'd1', + ).overrideWith((ref) async => >{}), + diveProvider('d1').overrideWith((ref) async => dive), + gasSwitchesProvider( + 'd1', + ).overrideWith((ref) async => []), + ], + ); + addTearDown(container.dispose); + + final result = await container.read( + estimatedTankPressuresProvider('d1').future, + ); + + expect(result.estimatedTankIds, isEmpty); + expect(result.pressures, isEmpty); + }, + ); + + test('estimates pressures when the preference is left on', () async { + final dive = Dive( + id: 'd1', + dateTime: DateTime(2026, 1, 1), + tanks: const [ + DiveTank( + id: 't1', + gasMix: GasMix(o2: 21), + startPressure: 200, + endPressure: 60, + ), + ], + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 0), + DiveProfilePoint(timestamp: 1800, depth: 0), + ], + ); + + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith( + (ref) => MockSettingsNotifier( + const AppSettings(defaultShowEstimatedTankPressure: true), + ), + ), + tankPressuresProvider( + 'd1', + ).overrideWith((ref) async => >{}), + diveProvider('d1').overrideWith((ref) async => dive), + gasSwitchesProvider( + 'd1', + ).overrideWith((ref) async => []), + ], + ); + addTearDown(container.dispose); + + final result = await container.read( + estimatedTankPressuresProvider('d1').future, + ); + + expect(result.estimatedTankIds, {'t1'}); + }); + + test('estimates pressures by default', () async { + expect(const AppSettings().defaultShowEstimatedTankPressure, isTrue); + }); } diff --git a/test/features/settings/data/repositories/diver_settings_repository_estimated_tank_pressure_test.dart b/test/features/settings/data/repositories/diver_settings_repository_estimated_tank_pressure_test.dart new file mode 100644 index 0000000000..a67ee52313 --- /dev/null +++ b/test/features/settings/data/repositories/diver_settings_repository_estimated_tank_pressure_test.dart @@ -0,0 +1,54 @@ +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 defaultShowEstimatedTankPressure', () { + 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 the estimate to on', () async { + // Issue #731: estimates shipped always-on, so the new preference has to + // default to true or an upgrade would silently drop the line. + await repository.createSettingsForDiver('d1'); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded, isNotNull); + expect(loaded!.defaultShowEstimatedTankPressure, isTrue); + }); + + test('round-trips the estimate turned off through update', () async { + await repository.createSettingsForDiver('d1'); + await repository.updateSettingsForDiver( + 'd1', + const AppSettings(defaultShowEstimatedTankPressure: false), + ); + final loaded = await repository.getSettingsForDiver('d1'); + expect(loaded, isNotNull); + expect(loaded!.defaultShowEstimatedTankPressure, isFalse); + }); + }); +} diff --git a/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart b/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart index efe7f8b73f..8a752b5213 100644 --- a/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart +++ b/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart @@ -29,6 +29,10 @@ class _StubSettingsNotifier extends StateNotifier Future setDefaultShowO2CellMv(bool value) async => state = state.copyWith(defaultShowO2CellMv: value); + @override + Future setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -155,4 +159,46 @@ void main() { await tester.pumpAndSettle(); expect(tester.widget(tile).value, isTrue); }); + + testWidgets('estimated tank pressure starts on', (tester) async { + // Issue #731: the estimate shipped always-on, so the preference preserves + // that as its default. + await tester.pumpWidget(buildPage(_StubSettingsNotifier())); + await tester.pumpAndSettle(); + + await tester.dragUntilVisible( + find.text('Estimated Tank Pressure'), + find.byType(Scrollable), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + + final tile = tester.widget( + find.ancestor( + of: find.text('Estimated Tank Pressure'), + matching: find.byType(SwitchListTile), + ), + ); + expect(tile.value, isTrue); + }); + + testWidgets('tapping Estimated Tank Pressure turns the estimate off', ( + tester, + ) async { + final notifier = _StubSettingsNotifier(); + await tester.pumpWidget(buildPage(notifier)); + await tester.pumpAndSettle(); + + await tester.dragUntilVisible( + find.text('Estimated Tank Pressure'), + find.byType(Scrollable), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Estimated Tank Pressure')); + await tester.pumpAndSettle(); + + expect(notifier.state.defaultShowEstimatedTankPressure, 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..26e70042e3 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 @@ -531,6 +531,9 @@ class _MockSettingsNotifier extends StateNotifier Future setDefaultShowO2CellMv(bool value) async => state = state.copyWith(defaultShowO2CellMv: value); @override + Future setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); @override diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index 879c192902..d8b1fc5af6 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -92,6 +92,9 @@ class _MockSettingsNotifier extends StateNotifier Future setDefaultShowO2CellMv(bool value) async => state = state.copyWith(defaultShowO2CellMv: value); @override + Future setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override Future setDefaultShowAscentRateLine(bool value) async => state = state.copyWith(defaultShowAscentRateLine: value); @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..688354b708 100644 --- a/test/features/statistics/presentation/pages/records_page_test.dart +++ b/test/features/statistics/presentation/pages/records_page_test.dart @@ -434,6 +434,9 @@ class _MockSettingsNotifier extends StateNotifier Future setDefaultShowO2CellMv(bool value) async => state = state.copyWith(defaultShowO2CellMv: value); @override + Future setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); @override diff --git a/test/helpers/mock_providers.dart b/test/helpers/mock_providers.dart index 269b46ce5c..f7140f0603 100644 --- a/test/helpers/mock_providers.dart +++ b/test/helpers/mock_providers.dart @@ -384,6 +384,9 @@ class MockSettingsNotifier extends StateNotifier Future setDefaultShowO2CellMv(bool value) async => state = state.copyWith(defaultShowO2CellMv: value); @override + Future setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override Future setDefaultShowGasTimeline(bool value) async => state = state.copyWith(defaultShowGasTimeline: value); @override From fe434528d36a9b8ee2702e064a7df9ddee7d1e90 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:21:46 -0400 Subject: [PATCH 057/122] feat(settings): place name language row and picker (#1187) --- .../pages/language_settings_page.dart | 32 +++--- .../presentation/pages/settings_page.dart | 22 ++++ .../widgets/place_name_language_picker.dart | 85 ++++++++++++++ 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 | 7 ++ lib/l10n/arb/app_localizations_de.dart | 7 ++ lib/l10n/arb/app_localizations_en.dart | 7 ++ lib/l10n/arb/app_localizations_es.dart | 8 ++ lib/l10n/arb/app_localizations_fr.dart | 7 ++ lib/l10n/arb/app_localizations_he.dart | 7 ++ lib/l10n/arb/app_localizations_hu.dart | 7 ++ lib/l10n/arb/app_localizations_it.dart | 7 ++ lib/l10n/arb/app_localizations_nl.dart | 7 ++ lib/l10n/arb/app_localizations_pt.dart | 7 ++ 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 + .../place_name_language_picker_test.dart | 107 ++++++++++++++++++ 27 files changed, 340 insertions(+), 18 deletions(-) create mode 100644 lib/features/settings/presentation/widgets/place_name_language_picker.dart create mode 100644 test/features/settings/presentation/widgets/place_name_language_picker_test.dart diff --git a/lib/features/settings/presentation/pages/language_settings_page.dart b/lib/features/settings/presentation/pages/language_settings_page.dart index 3dc1cc76df..320eafcdd7 100644 --- a/lib/features/settings/presentation/pages/language_settings_page.dart +++ b/lib/features/settings/presentation/pages/language_settings_page.dart @@ -9,34 +9,30 @@ class LanguageSettingsPage extends ConsumerWidget { const LanguageSettingsPage({super.key}); static const supportedLocales = [ - _LocaleOption( - code: 'system', - nativeName: 'System Default', - englishName: '', - ), - _LocaleOption(code: 'en', nativeName: 'English', englishName: 'English'), - _LocaleOption(code: 'es', nativeName: 'Espanol', englishName: 'Spanish'), - _LocaleOption(code: 'fr', nativeName: 'Francais', englishName: 'French'), - _LocaleOption(code: 'de', nativeName: 'Deutsch', englishName: 'German'), - _LocaleOption(code: 'it', nativeName: 'Italiano', englishName: 'Italian'), - _LocaleOption(code: 'nl', nativeName: 'Nederlands', englishName: 'Dutch'), - _LocaleOption( + LocaleOption(code: 'system', nativeName: 'System Default', englishName: ''), + LocaleOption(code: 'en', nativeName: 'English', englishName: 'English'), + LocaleOption(code: 'es', nativeName: 'Espanol', englishName: 'Spanish'), + LocaleOption(code: 'fr', nativeName: 'Francais', englishName: 'French'), + LocaleOption(code: 'de', nativeName: 'Deutsch', englishName: 'German'), + LocaleOption(code: 'it', nativeName: 'Italiano', englishName: 'Italian'), + LocaleOption(code: 'nl', nativeName: 'Nederlands', englishName: 'Dutch'), + LocaleOption( code: 'pt', nativeName: 'Portugues', englishName: 'Portuguese', ), - _LocaleOption(code: 'hu', nativeName: 'Magyar', englishName: 'Hungarian'), - _LocaleOption( + LocaleOption(code: 'hu', nativeName: 'Magyar', englishName: 'Hungarian'), + LocaleOption( code: 'ar', nativeName: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629', englishName: 'Arabic', ), - _LocaleOption( + LocaleOption( code: 'he', nativeName: '\u05E2\u05D1\u05E8\u05D9\u05EA', englishName: 'Hebrew', ), - _LocaleOption( + LocaleOption( code: 'zh', nativeName: '简体中文', englishName: 'Chinese (Simplified)', @@ -97,12 +93,12 @@ class LanguageSettingsPage extends ConsumerWidget { } } -class _LocaleOption { +class LocaleOption { final String code; final String nativeName; final String englishName; - const _LocaleOption({ + const LocaleOption({ required this.code, required this.nativeName, required this.englishName, diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 0b722cb84f..6507d51b46 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -16,6 +16,7 @@ import 'package:submersion/features/settings/presentation/pages/safety_settings_ import 'package:submersion/features/settings/presentation/pages/security_settings_page.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/settings/presentation/widgets/coordinate_format_picker.dart'; +import 'package:submersion/features/settings/presentation/widgets/place_name_language_picker.dart'; import 'package:submersion/features/settings/presentation/widgets/visibility_scale_picker.dart'; import 'package:submersion/core/constants/profile_metrics.dart'; import 'package:submersion/features/settings/presentation/pages/home_appearance_page.dart'; @@ -555,6 +556,27 @@ class _UnitsSectionContent extends ConsumerWidget { onTap: () => showCoordinateFormatPicker(context, ref, settings), ), + const Divider(height: 1), + ListTile( + title: Text(context.l10n.settings_placeNameLanguage_title), + subtitle: Text( + context.l10n.settings_placeNameLanguage_subtitle, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + placeNameLanguageLabel(settings.placeNameLanguage), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => + showPlaceNameLanguagePicker(context, ref, settings), + ), ], ), ), diff --git a/lib/features/settings/presentation/widgets/place_name_language_picker.dart b/lib/features/settings/presentation/widgets/place_name_language_picker.dart new file mode 100644 index 0000000000..632bc726be --- /dev/null +++ b/lib/features/settings/presentation/widgets/place_name_language_picker.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/constants/place_name_language.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/pages/language_settings_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// The place name language picker (issue #1187), split out of +/// `settings_page.dart` so it can be pumped directly in tests. +/// +/// The options are the app's own languages minus "System Default": the value +/// must resolve to the same code on every one of the diver's devices, which +/// a device-dependent choice cannot promise. + +/// Opens the place name language picker. +void showPlaceNameLanguagePicker( + BuildContext context, + WidgetRef ref, + AppSettings settings, +) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text( + AppLocalizations.of(context).settings_placeNameLanguage_title, + ), + content: PlaceNameLanguageList( + selected: settings.placeNameLanguage, + onSelected: (code) { + Navigator.of(dialogContext).pop(); + ref.read(settingsProvider.notifier).setPlaceNameLanguage(code); + }, + ), + ), + ); +} + +/// The supported languages, each by its native name. +class PlaceNameLanguageList extends StatelessWidget { + const PlaceNameLanguageList({ + super.key, + required this.selected, + required this.onSelected, + }); + + final String selected; + final void Function(String code) onSelected; + + @override + Widget build(BuildContext context) { + // A Column rather than a lazy ListView: eleven rows are cheap, and every + // option then exists in the tree, which keeps the picker testable. + return SizedBox( + width: 360, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final code in PlaceNameLanguage.supportedCodes) + ListTile( + title: Text(placeNameLanguageLabel(code)), + trailing: code == selected + ? Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () => onSelected(code), + ), + ], + ), + ), + ); + } +} + +/// The native name of a language code, from the app language list, so there +/// is no second hand-maintained list of names. +String placeNameLanguageLabel(String code) { + for (final option in LanguageSettingsPage.supportedLocales) { + if (option.code == code) return option.nativeName; + } + return code; +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..c965b5dee1 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "أقل من {max} {unit}", "settings_coordinateFormat_title": "تنسيق الإحداثيات", "settings_coordinateFormat_subtitle": "كيفية عرض مواقع GPS وإدخالها", + "settings_placeNameLanguage_title": "لغة أسماء الأماكن", + "settings_placeNameLanguage_subtitle": "تُستخدم عند البحث عن البلد والمنطقة والبلدة والمسطح المائي من الإحداثيات. لا يتم تغيير المواقع الحالية.", "settings_coordinateFormat_decimalDegrees": "درجات عشرية", "settings_coordinateFormat_degreesDecimalMinutes": "درجات ودقائق عشرية", "settings_coordinateFormat_degreesMinutesSeconds": "درجات ودقائق وثوانٍ", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..879cd37563 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "unter {max} {unit}", "settings_coordinateFormat_title": "Koordinatenformat", "settings_coordinateFormat_subtitle": "Wie GPS-Positionen angezeigt und eingegeben werden", + "settings_placeNameLanguage_title": "Sprache der Ortsnamen", + "settings_placeNameLanguage_subtitle": "Wird verwendet, wenn Land, Region, Ort und Gewässer aus Koordinaten ermittelt werden. Bestehende Tauchplätze werden nicht geändert.", "settings_coordinateFormat_decimalDegrees": "Dezimalgrad", "settings_coordinateFormat_degreesDecimalMinutes": "Grad und Dezimalminuten", "settings_coordinateFormat_degreesMinutesSeconds": "Grad, Minuten, Sekunden", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..9e623a70a7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -16087,6 +16087,8 @@ }, "settings_coordinateFormat_title": "Coordinate format", "settings_coordinateFormat_subtitle": "How GPS positions are shown and entered", + "settings_placeNameLanguage_title": "Place name language", + "settings_placeNameLanguage_subtitle": "Used when country, region, town and body of water are looked up from coordinates. Existing sites are not changed.", "settings_coordinateFormat_decimalDegrees": "Decimal degrees", "settings_coordinateFormat_degreesDecimalMinutes": "Degrees and decimal minutes", "settings_coordinateFormat_degreesMinutesSeconds": "Degrees, minutes, seconds", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..440cb578e9 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "menos de {max} {unit}", "settings_coordinateFormat_title": "Formato de coordenadas", "settings_coordinateFormat_subtitle": "Cómo se muestran e introducen las posiciones GPS", + "settings_placeNameLanguage_title": "Idioma de los nombres de lugar", + "settings_placeNameLanguage_subtitle": "Se usa al obtener país, región, localidad y masa de agua a partir de las coordenadas. Los puntos de buceo existentes no cambian.", "settings_coordinateFormat_decimalDegrees": "Grados decimales", "settings_coordinateFormat_degreesDecimalMinutes": "Grados y minutos decimales", "settings_coordinateFormat_degreesMinutesSeconds": "Grados, minutos, segundos", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..e985bff5d8 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "moins de {max} {unit}", "settings_coordinateFormat_title": "Format des coordonnées", "settings_coordinateFormat_subtitle": "Comment les positions GPS sont affichées et saisies", + "settings_placeNameLanguage_title": "Langue des noms de lieux", + "settings_placeNameLanguage_subtitle": "Utilisée lorsque le pays, la région, la ville et le plan d'eau sont déduits des coordonnées. Les sites existants ne sont pas modifiés.", "settings_coordinateFormat_decimalDegrees": "Degrés décimaux", "settings_coordinateFormat_degreesDecimalMinutes": "Degrés et minutes décimales", "settings_coordinateFormat_degreesMinutesSeconds": "Degrés, minutes, secondes", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..12aa4dc079 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "מתחת ל-{max} {unit}", "settings_coordinateFormat_title": "פורמט קואורדינטות", "settings_coordinateFormat_subtitle": "כיצד מוצגים ומוזנים מיקומי GPS", + "settings_placeNameLanguage_title": "שפת שמות המקומות", + "settings_placeNameLanguage_subtitle": "בשימוש כאשר מדינה, אזור, עיר וגוף מים נשלפים מהקואורדינטות. אתרים קיימים אינם משתנים.", "settings_coordinateFormat_decimalDegrees": "מעלות עשרוניות", "settings_coordinateFormat_degreesDecimalMinutes": "מעלות ודקות עשרוניות", "settings_coordinateFormat_degreesMinutesSeconds": "מעלות, דקות, שניות", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..c76b198dd6 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "kevesebb mint {max} {unit}", "settings_coordinateFormat_title": "Koordináta-formátum", "settings_coordinateFormat_subtitle": "Hogyan jelennek meg és hogyan adhatók meg a GPS-pozíciók", + "settings_placeNameLanguage_title": "Helynevek nyelve", + "settings_placeNameLanguage_subtitle": "Akkor használjuk, amikor az ország, régió, település és víztest a koordinátákból kerül lekérdezésre. A meglévő merülőhelyek nem változnak.", "settings_coordinateFormat_decimalDegrees": "Tizedes fok", "settings_coordinateFormat_degreesDecimalMinutes": "Fok és tizedes perc", "settings_coordinateFormat_degreesMinutesSeconds": "Fok, perc, másodperc", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..331c46b468 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "meno di {max} {unit}", "settings_coordinateFormat_title": "Formato delle coordinate", "settings_coordinateFormat_subtitle": "Come vengono mostrate e inserite le posizioni GPS", + "settings_placeNameLanguage_title": "Lingua dei nomi dei luoghi", + "settings_placeNameLanguage_subtitle": "Usata quando paese, regione, città e specchio d'acqua vengono ricavati dalle coordinate. I siti esistenti non vengono modificati.", "settings_coordinateFormat_decimalDegrees": "Gradi decimali", "settings_coordinateFormat_degreesDecimalMinutes": "Gradi e minuti decimali", "settings_coordinateFormat_degreesMinutesSeconds": "Gradi, minuti, secondi", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..254dc5ccd2 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -41660,6 +41660,18 @@ abstract class AppLocalizations { /// **'How GPS positions are shown and entered'** String get settings_coordinateFormat_subtitle; + /// No description provided for @settings_placeNameLanguage_title. + /// + /// In en, this message translates to: + /// **'Place name language'** + String get settings_placeNameLanguage_title; + + /// No description provided for @settings_placeNameLanguage_subtitle. + /// + /// In en, this message translates to: + /// **'Used when country, region, town and body of water are looked up from coordinates. Existing sites are not changed.'** + String get settings_placeNameLanguage_subtitle; + /// No description provided for @settings_coordinateFormat_decimalDegrees. /// /// 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..fbc94c8ec6 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -24578,6 +24578,13 @@ class AppLocalizationsAr extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'كيفية عرض مواقع GPS وإدخالها'; + @override + String get settings_placeNameLanguage_title => 'لغة أسماء الأماكن'; + + @override + String get settings_placeNameLanguage_subtitle => + 'تُستخدم عند البحث عن البلد والمنطقة والبلدة والمسطح المائي من الإحداثيات. لا يتم تغيير المواقع الحالية.'; + @override String get settings_coordinateFormat_decimalDegrees => 'درجات عشرية'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..65b72636c7 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -24981,6 +24981,13 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'Wie GPS-Positionen angezeigt und eingegeben werden'; + @override + String get settings_placeNameLanguage_title => 'Sprache der Ortsnamen'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Wird verwendet, wenn Land, Region, Ort und Gewässer aus Koordinaten ermittelt werden. Bestehende Tauchplätze werden nicht geändert.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Dezimalgrad'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..6f990229b0 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -24605,6 +24605,13 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'How GPS positions are shown and entered'; + @override + String get settings_placeNameLanguage_title => 'Place name language'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Used when country, region, town and body of water are looked up from coordinates. Existing sites are not changed.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Decimal degrees'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..784f0bb619 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -25035,6 +25035,14 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'Cómo se muestran e introducen las posiciones GPS'; + @override + String get settings_placeNameLanguage_title => + 'Idioma de los nombres de lugar'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Se usa al obtener país, región, localidad y masa de agua a partir de las coordenadas. Los puntos de buceo existentes no cambian.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Grados decimales'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..0f36721450 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -25098,6 +25098,13 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'Comment les positions GPS sont affichées et saisies'; + @override + String get settings_placeNameLanguage_title => 'Langue des noms de lieux'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Utilisée lorsque le pays, la région, la ville et le plan d\'eau sont déduits des coordonnées. Les sites existants ne sont pas modifiés.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Degrés décimaux'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..39b488f4ea 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -24400,6 +24400,13 @@ class AppLocalizationsHe extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'כיצד מוצגים ומוזנים מיקומי GPS'; + @override + String get settings_placeNameLanguage_title => 'שפת שמות המקומות'; + + @override + String get settings_placeNameLanguage_subtitle => + 'בשימוש כאשר מדינה, אזור, עיר וגוף מים נשלפים מהקואורדינטות. אתרים קיימים אינם משתנים.'; + @override String get settings_coordinateFormat_decimalDegrees => 'מעלות עשרוניות'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..3bf45a67d2 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -24936,6 +24936,13 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'Hogyan jelennek meg és hogyan adhatók meg a GPS-pozíciók'; + @override + String get settings_placeNameLanguage_title => 'Helynevek nyelve'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Akkor használjuk, amikor az ország, régió, település és víztest a koordinátákból kerül lekérdezésre. A meglévő merülőhelyek nem változnak.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Tizedes fok'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..ea7196aa98 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -25021,6 +25021,13 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'Come vengono mostrate e inserite le posizioni GPS'; + @override + String get settings_placeNameLanguage_title => 'Lingua dei nomi dei luoghi'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Usata quando paese, regione, città e specchio d\'acqua vengono ricavati dalle coordinate. I siti esistenti non vengono modificati.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Gradi decimali'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..867c0b7562 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -24838,6 +24838,13 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'Hoe GPS-posities worden weergegeven en ingevoerd'; + @override + String get settings_placeNameLanguage_title => 'Taal van plaatsnamen'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Gebruikt wanneer land, regio, plaats en water uit coördinaten worden opgezocht. Bestaande duikstekken worden niet gewijzigd.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Decimale graden'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..d236d6ad75 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -25016,6 +25016,13 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_coordinateFormat_subtitle => 'Como as posições GPS são apresentadas e introduzidas'; + @override + String get settings_placeNameLanguage_title => 'Idioma dos nomes de lugares'; + + @override + String get settings_placeNameLanguage_subtitle => + 'Usado quando país, região, cidade e corpo de água são obtidos a partir das coordenadas. Os locais existentes não são alterados.'; + @override String get settings_coordinateFormat_decimalDegrees => 'Graus decimais'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..509e3a7c40 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -23766,6 +23766,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_coordinateFormat_subtitle => 'GPS 位置的显示和输入方式'; + @override + String get settings_placeNameLanguage_title => '地名语言'; + + @override + String get settings_placeNameLanguage_subtitle => + '根据坐标查找国家、地区、城镇和水域时使用。现有潜点不会更改。'; + @override String get settings_coordinateFormat_decimalDegrees => '十进制度'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..d684cad21c 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "minder dan {max} {unit}", "settings_coordinateFormat_title": "Coördinaatformaat", "settings_coordinateFormat_subtitle": "Hoe GPS-posities worden weergegeven en ingevoerd", + "settings_placeNameLanguage_title": "Taal van plaatsnamen", + "settings_placeNameLanguage_subtitle": "Gebruikt wanneer land, regio, plaats en water uit coördinaten worden opgezocht. Bestaande duikstekken worden niet gewijzigd.", "settings_coordinateFormat_decimalDegrees": "Decimale graden", "settings_coordinateFormat_degreesDecimalMinutes": "Graden en decimale minuten", "settings_coordinateFormat_degreesMinutesSeconds": "Graden, minuten, seconden", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..29edf2aad4 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "menos de {max} {unit}", "settings_coordinateFormat_title": "Formato das coordenadas", "settings_coordinateFormat_subtitle": "Como as posições GPS são apresentadas e introduzidas", + "settings_placeNameLanguage_title": "Idioma dos nomes de lugares", + "settings_placeNameLanguage_subtitle": "Usado quando país, região, cidade e corpo de água são obtidos a partir das coordenadas. Os locais existentes não são alterados.", "settings_coordinateFormat_decimalDegrees": "Graus decimais", "settings_coordinateFormat_degreesDecimalMinutes": "Graus e minutos decimais", "settings_coordinateFormat_degreesMinutesSeconds": "Graus, minutos, segundos", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..ff42be42ff 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -7554,6 +7554,8 @@ "visibility_range_under": "不足 {max} {unit}", "settings_coordinateFormat_title": "坐标格式", "settings_coordinateFormat_subtitle": "GPS 位置的显示和输入方式", + "settings_placeNameLanguage_title": "地名语言", + "settings_placeNameLanguage_subtitle": "根据坐标查找国家、地区、城镇和水域时使用。现有潜点不会更改。", "settings_coordinateFormat_decimalDegrees": "十进制度", "settings_coordinateFormat_degreesDecimalMinutes": "度和十进制分", "settings_coordinateFormat_degreesMinutesSeconds": "度分秒", diff --git a/test/features/settings/presentation/widgets/place_name_language_picker_test.dart b/test/features/settings/presentation/widgets/place_name_language_picker_test.dart new file mode 100644 index 0000000000..914790cb01 --- /dev/null +++ b/test/features/settings/presentation/widgets/place_name_language_picker_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/place_name_language_picker.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Stands in for SettingsNotifier so the picker's saves can be inspected +/// without a database. Only setPlaceNameLanguage is exercised here. +class _RecordingSettingsNotifier extends StateNotifier + implements SettingsNotifier { + final List saved; + + _RecordingSettingsNotifier(super.initial, this.saved); + + @override + Future setPlaceNameLanguage(String code) async { + state = state.copyWith(placeNameLanguage: code); + saved.add(code); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late List saved; + late ProviderContainer container; + + setUp(() { + saved = []; + container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith( + (ref) => _RecordingSettingsNotifier(const AppSettings(), saved), + ), + ], + ); + addTearDown(container.dispose); + }); + + Widget host(Widget child) => UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), + ); + + Future openPicker(WidgetTester tester) async { + await tester.pumpWidget( + host( + Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => showPlaceNameLanguagePicker( + context, + ref, + container.read(settingsProvider), + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('offers every supported language by its native name', ( + tester, + ) async { + await openPicker(tester); + for (final name in ['English', 'Deutsch', 'Espanol', 'Magyar', '简体中文']) { + expect(find.text(name), findsOneWidget, reason: 'missing $name'); + } + expect(find.text('System Default'), findsNothing); + }); + + testWidgets('marks the current language', (tester) async { + await openPicker(tester); + final tile = find.ancestor( + of: find.text('English'), + matching: find.byType(ListTile), + ); + expect( + find.descendant(of: tile, matching: find.byIcon(Icons.check)), + findsOneWidget, + ); + }); + + testWidgets('selecting a language saves it and closes', (tester) async { + await openPicker(tester); + await tester.tap(find.text('Deutsch')); + await tester.pumpAndSettle(); + + expect(saved, ['de']); + expect(find.byType(PlaceNameLanguageList), findsNothing); + }); + + test('the label is the native name, falling back to the code', () { + expect(placeNameLanguageLabel('de'), 'Deutsch'); + expect(placeNameLanguageLabel('en'), 'English'); + expect(placeNameLanguageLabel('xx'), 'xx'); + }); +} From 5d4423373de07516c31b7f83285bf5dcbf44e093 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:22:12 -0400 Subject: [PATCH 058/122] test(sync): pin Intl.defaultLocale in the conflict scalar format test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatter builds bare DateFormats, which resolve against Intl.defaultLocale, a process global the test never pinned. Verified the exposure is real rather than theoretical: the epoch assertion renders as "أغسطس ١٢, ٢٠٢٦" under ar_EG and "आगस्ट १२, २०२६" under ne, so contains('2026') fails outright on any host resolving to a locale with Eastern digits. Pin and restore the global. This file is a pure unit test with no MaterialApp, so it also has to await initializeDateFormatting('en') first: assigning Intl.defaultLocale makes intl stop using its implicit fallback and demand real symbol data, and without the init the file fails with LocaleDataException. The widget-test precedent pins without initializing because GlobalMaterialLocalizations has already done it there. The other two new test files need no pin: the resolver test compares DateTime values rather than formatted strings, and the dialog widget test asserts only on the ICU pattern's parentheses, never on the date inside them. --- .../widgets/conflict_scalar_format_test.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart index 66fcbcbffc..473e209496 100644 --- a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart +++ b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -11,11 +13,23 @@ import 'package:submersion/l10n/arb/app_localizations.dart'; /// metric depth to an imperial diver and an epoch integer to everyone. void main() { late AppLocalizations l10n; + String? savedLocale; setUpAll(() async { l10n = await AppLocalizations.delegate.load(const Locale('en')); + // UnitFormatter builds bare DateFormats, which resolve against + // Intl.defaultLocale -- a process global. Unpinned, this file rides on + // intl's implicit fallback: under ar_EG, fa, bn or ne the year renders in + // Eastern digits and the assertions below stop matching. This is a pure + // unit test with no MaterialApp, so the date symbols have to be loaded + // before the global is assigned or intl throws LocaleDataException. + await initializeDateFormatting('en'); + savedLocale = Intl.defaultLocale; + Intl.defaultLocale = 'en'; }); + tearDownAll(() => Intl.defaultLocale = savedLocale); + String format(UnitFormatter units, String key, Object value) => formatConflictScalar(l10n, units, key, value); From 6fc164f205b1553575a88a2e39ed375bdc692bd4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:26:50 -0400 Subject: [PATCH 059/122] feat(location): geocode in the diver's place name language (#1187) --- .../data/services/uddf_entity_importer.dart | 11 +- .../presentation/pages/site_edit_page.dart | 4 +- .../widgets/location_picker_map.dart | 4 +- .../data/adapters/universal_adapter.dart | 1 + .../widgets/region_download_dialog.dart | 3 +- test/core/services/location_service_test.dart | 179 +++--------------- .../uddf_entity_importer_language_test.dart | 79 ++++++++ .../pages/site_edit_language_test.dart | 92 +++++++++ test/helpers/fake_nominatim.dart | 140 ++++++++++++++ 9 files changed, 351 insertions(+), 162 deletions(-) create mode 100644 test/features/dive_import/data/services/uddf_entity_importer_language_test.dart create mode 100644 test/features/dive_sites/presentation/pages/site_edit_language_test.dart create mode 100644 test/helpers/fake_nominatim.dart diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index f9452b9e69..190f1185bd 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -227,13 +227,18 @@ class UddfEntityImporter { final int _defaultStartPressure; final bool _applyDefaultTankToImports; + /// ISO 639-1 code for reverse-geocoded country/region (issue #1187). + final String _placeNameLanguage; + UddfEntityImporter({ TankPresetEntity? defaultTankPreset, int defaultStartPressure = 200, bool applyDefaultTankToImports = false, + String placeNameLanguage = LocationService.defaultLanguageCode, }) : _defaultTankPreset = defaultTankPreset, _defaultStartPressure = defaultStartPressure, - _applyDefaultTankToImports = applyDefaultTankToImports; + _applyDefaultTankToImports = applyDefaultTankToImports, + _placeNameLanguage = placeNameLanguage; /// Parse a value that may be either an enum instance or a string matching /// an enum name. Returns null if the value is null or unrecognised. @@ -1035,7 +1040,7 @@ class UddfEntityImporter { final geocodeResult = await LocationService.instance.reverseGeocode( lat, lon, - languageCode: LocationService.defaultLanguageCode, + languageCode: _placeNameLanguage, ); country ??= geocodeResult.country; region ??= geocodeResult.region; @@ -1109,7 +1114,7 @@ class UddfEntityImporter { final geocodeResult = await LocationService.instance.reverseGeocode( lat, lon, - languageCode: LocationService.defaultLanguageCode, + languageCode: _placeNameLanguage, ); country ??= geocodeResult.country; region ??= geocodeResult.region; diff --git a/lib/features/dive_sites/presentation/pages/site_edit_page.dart b/lib/features/dive_sites/presentation/pages/site_edit_page.dart index 5792c0386e..6d97f015c0 100644 --- a/lib/features/dive_sites/presentation/pages/site_edit_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_edit_page.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/providers/location_service_provider.dart'; -import 'package:submersion/core/services/location_service.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; @@ -215,7 +214,7 @@ class _SiteEditPageState extends ConsumerState { .reverseGeocode( loc.latitude, loc.longitude, - languageCode: LocationService.defaultLanguageCode, + languageCode: ref.read(placeNameLanguageProvider), ); if (!mounted) return; setState(() { @@ -1348,6 +1347,7 @@ class _SiteEditPageState extends ConsumerState { final locationService = ref.read(locationServiceProvider); final result = await locationService.getCurrentLocation( includeGeocoding: true, + languageCode: ref.read(placeNameLanguageProvider), ); if (result == null) { diff --git a/lib/features/dive_sites/presentation/widgets/location_picker_map.dart b/lib/features/dive_sites/presentation/widgets/location_picker_map.dart index 5d2b7182b4..c6efbe38f4 100644 --- a/lib/features/dive_sites/presentation/widgets/location_picker_map.dart +++ b/lib/features/dive_sites/presentation/widgets/location_picker_map.dart @@ -64,7 +64,7 @@ class _LocationPickerMapState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( _selectedLocation!.latitude, _selectedLocation!.longitude, - languageCode: LocationService.defaultLanguageCode, + languageCode: ref.read(placeNameLanguageProvider), ); if (mounted) { @@ -100,7 +100,7 @@ class _LocationPickerMapState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( _selectedLocation!.latitude, _selectedLocation!.longitude, - languageCode: LocationService.defaultLanguageCode, + languageCode: ref.read(placeNameLanguageProvider), ); if (mounted) { diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index 73f895e508..c10351afaa 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -526,6 +526,7 @@ class UniversalAdapter implements ImportSourceAdapter { defaultTankPreset: defaultTankPreset, defaultStartPressure: settings.defaultStartPressure, applyDefaultTankToImports: settings.applyDefaultTankToImports, + placeNameLanguage: settings.placeNameLanguage, ); final result = await importer.import( diff --git a/lib/features/maps/presentation/widgets/region_download_dialog.dart b/lib/features/maps/presentation/widgets/region_download_dialog.dart index 33868577f0..bc01df8d78 100644 --- a/lib/features/maps/presentation/widgets/region_download_dialog.dart +++ b/lib/features/maps/presentation/widgets/region_download_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/core/services/location_service.dart'; import 'package:submersion/features/maps/presentation/providers/map_tile_providers.dart'; import 'package:submersion/features/maps/presentation/providers/offline_map_providers.dart'; @@ -71,7 +72,7 @@ class _RegionDownloadDialogState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( centerLat, centerLng, - languageCode: LocationService.defaultLanguageCode, + languageCode: ref.read(placeNameLanguageProvider), ); if (mounted && _nameController.text.isEmpty) { diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index 22368eb102..cc02b85bd0 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:ui' show Locale; @@ -15,135 +14,7 @@ import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/location_service.dart'; -/// One canned HTTP exchange plus a record of what the service actually sent. -/// -/// The geocoding paths talk to Nominatim through `dart:io HttpClient`, so the -/// only seam that does not require a real socket is [HttpOverrides]. Every -/// request the service makes is captured here so the tests can assert on the -/// English pin (#214) that lives in the URI *and* in the request headers. -class _FakeNominatim { - _FakeNominatim({this.statusCode = 200, this.body = '{}', this.bodyFor}); - - final int statusCode; - final String body; - - /// When set, wins over [body] for the given request. - final String? Function(Uri uri)? bodyFor; - - String bodyForUri(Uri uri) => bodyFor?.call(uri) ?? body; - - final List requestedUris = []; - final List> requestHeaders = >[]; - int clientCloseCount = 0; - - Uri get lastUri => requestedUris.last; - Map get lastHeaders => requestHeaders.last; - - /// Run [body] with every `HttpClient` replaced by this fake server. - Future run(Future Function() action) => - HttpOverrides.runZoned>( - action, - createHttpClient: (SecurityContext? _) => _FakeHttpClient(this), - ); -} - -class _FakeHttpClient implements HttpClient { - _FakeHttpClient(this._server); - - final _FakeNominatim _server; - - @override - String? userAgent; - - @override - Future getUrl(Uri url) async { - _server.requestedUris.add(url); - return _FakeHttpClientRequest(url, _server); - } - - @override - void close({bool force = false}) => _server.clientCloseCount++; - - @override - dynamic noSuchMethod(Invocation invocation) => null; -} - -class _ThrowingHttpClient implements HttpClient { - @override - String? userAgent; - - @override - Future getUrl(Uri url) async { - throw const SocketException('offline'); - } - - @override - void close({bool force = false}) {} - - @override - dynamic noSuchMethod(Invocation invocation) => null; -} - -class _FakeHttpClientRequest implements HttpClientRequest { - _FakeHttpClientRequest(this.uri, this._server); - - final _FakeNominatim _server; - - @override - final Uri uri; - - @override - final HttpHeaders headers = _FakeHttpHeaders(); - - @override - Future close() async { - _server.requestHeaders.add((headers as _FakeHttpHeaders).values); - return _FakeHttpClientResponse(_server.statusCode, _server.bodyForUri(uri)); - } - - @override - dynamic noSuchMethod(Invocation invocation) => null; -} - -class _FakeHttpHeaders implements HttpHeaders { - final Map values = {}; - - @override - void set(String name, Object value, {bool preserveHeaderCase = false}) { - values[name.toLowerCase()] = '$value'; - } - - @override - dynamic noSuchMethod(Invocation invocation) => null; -} - -class _FakeHttpClientResponse extends Stream> - implements HttpClientResponse { - _FakeHttpClientResponse(this.statusCode, this._body); - - @override - final int statusCode; - - final String _body; - - @override - StreamSubscription> listen( - void Function(List event)? onData, { - Function? onError, - void Function()? onDone, - bool? cancelOnError, - }) { - return Stream>.value(utf8.encode(_body)).listen( - onData, - onError: onError, - onDone: onDone, - cancelOnError: cancelOnError, - ); - } - - @override - dynamic noSuchMethod(Invocation invocation) => null; -} +import '../../helpers/fake_nominatim.dart'; void main() { final service = LocationService.instance; @@ -181,7 +52,7 @@ void main() { test( 'parses country, region and locality from a Nominatim response', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode({ 'address': { 'country': 'Spain', @@ -204,7 +75,7 @@ void main() { test( 'sends the English pin in both the URI and the request headers', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode({ 'address': {'country': 'Spain'}, }), @@ -236,7 +107,7 @@ void main() { ); test('sends the requested language in the URI and the headers', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode({ 'address': {'country': 'Schweiz'}, }), @@ -254,7 +125,7 @@ void main() { test('returns PlaceLookup.unavailable when the request throws', () async { final result = await HttpOverrides.runZoned( () => service.reverseGeocode(47.0, 8.4, languageCode: 'en'), - createHttpClient: (_) => _ThrowingHttpClient(), + createHttpClient: (_) => ThrowingHttpClient(), ); expect(result.isEmpty, isTrue); @@ -262,7 +133,7 @@ void main() { }); test('falls back from state to province for the region', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode({ 'address': { 'country': 'Canada', @@ -281,7 +152,7 @@ void main() { }); test('falls back from province to region, and to village', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode({ 'address': { 'country': 'Egypt', @@ -303,7 +174,7 @@ void main() { test( 'returns empty fields when the payload has no address block', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode({'error': 'Unable to geocode'}), ); @@ -318,7 +189,7 @@ void main() { ); test('returns empty fields on a non-200 response', () async { - final server = _FakeNominatim( + final server = FakeNominatim( statusCode: 503, body: 'Service Unavailable', ); @@ -333,7 +204,7 @@ void main() { }); test('swallows malformed JSON instead of throwing', () async { - final server = _FakeNominatim(body: 'rate limited'); + final server = FakeNominatim(body: 'rate limited'); final result = await server.run( () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), @@ -345,7 +216,7 @@ void main() { }); test('closes the HttpClient even when the body fails to parse', () async { - final server = _FakeNominatim(body: 'not json'); + final server = FakeNominatim(body: 'not json'); await server.run( () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), @@ -363,7 +234,7 @@ void main() { group('forwardGeocode', () { test('returns the parsed coordinates and address details', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode([ { 'lat': '36.0143', @@ -391,7 +262,7 @@ void main() { test( 'sends the English pin in both the URI and the request headers', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode([ {'lat': '36.0143', 'lon': '-5.6044'}, ]), @@ -407,7 +278,7 @@ void main() { ); test('falls back from state to province, and from city to town', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode([ { 'lat': '45.2542', @@ -430,7 +301,7 @@ void main() { }); test('falls back to region and village as the last options', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode([ { 'lat': '28.5091', @@ -453,7 +324,7 @@ void main() { test( 'returns coordinates with null details when address is absent', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode([ {'lat': '12.5', 'lon': '-70.0'}, ]), @@ -470,7 +341,7 @@ void main() { ); test('returns null when Nominatim has no match', () async { - final server = _FakeNominatim(body: '[]'); + final server = FakeNominatim(body: '[]'); final result = await server.run( () => service.forwardGeocode('Nowhere At All'), @@ -480,7 +351,7 @@ void main() { }); test('returns null when the coordinates are not parseable', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode([ {'lat': 'not-a-number', 'lon': '-5.6044'}, ]), @@ -492,7 +363,7 @@ void main() { }); test('returns null on a non-200 response', () async { - final server = _FakeNominatim(statusCode: 429, body: 'Too Many Requests'); + final server = FakeNominatim(statusCode: 429, body: 'Too Many Requests'); final result = await server.run(() => service.forwardGeocode('Tarifa')); @@ -505,7 +376,7 @@ void main() { }); test('swallows malformed JSON instead of throwing', () async { - final server = _FakeNominatim(body: 'rate limited'); + final server = FakeNominatim(body: 'rate limited'); final result = await server.run(() => service.forwardGeocode('Tarifa')); @@ -515,7 +386,7 @@ void main() { test( 'short-circuits a blank address without hitting the network', () async { - final server = _FakeNominatim(body: '[]'); + final server = FakeNominatim(body: '[]'); final result = await server.run(() => service.forwardGeocode(' ')); @@ -592,7 +463,7 @@ void main() { // The first attempt throws inside the native branch; the service falls // through to the web fallback rather than surfacing the failure. - final server = _FakeNominatim( + final server = FakeNominatim( body: '{"address": {"country": "Fallback"}}', ); final first = await server.run( @@ -646,7 +517,7 @@ void main() { }); test('a lake on the natural layer becomes the body of water', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode(address()), bodyFor: (uri) => natural(uri, { 'class': 'water', @@ -731,7 +602,7 @@ void main() { test('a failing natural-layer request keeps the address result', () async { var calls = 0; - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode(address()), bodyFor: (uri) { if (uri.queryParameters['layer'] != 'natural') return null; @@ -756,7 +627,7 @@ void main() { LocationService.throttle = NominatimThrottle(); final start = clock.now(); final seenAt = []; - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode(address()), bodyFor: (uri) { seenAt.add(clock.now().difference(start)); diff --git a/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart b/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart new file mode 100644 index 0000000000..5f4a1f81a7 --- /dev/null +++ b/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:submersion/core/services/geocoding/nominatim_throttle.dart'; +import 'package:submersion/core/services/export/models/uddf_import_result.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +import '../../../../helpers/fake_nominatim.dart'; +import 'uddf_entity_importer_test.mocks.dart'; + +/// Issue #1187: the importer's own country/region lookup for sites that +/// arrive with coordinates but no address must use the diver's place name +/// language, not the old English pin. +void main() { + late MockSiteRepository sites; + late ImportRepositories repos; + + setUp(() { + LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + sites = MockSiteRepository(); + when( + sites.getAllSites(diverId: anyNamed('diverId')), + ).thenAnswer((_) async => []); + when(sites.createSite(any)).thenAnswer( + (invocation) async => invocation.positionalArguments[0] as DiveSite, + ); + repos = ImportRepositories( + tripRepository: MockTripRepository(), + equipmentRepository: MockEquipmentRepository(), + equipmentSetRepository: MockEquipmentSetRepository(), + buddyRepository: MockBuddyRepository(), + diveCenterRepository: MockDiveCenterRepository(), + certificationRepository: MockCertificationRepository(), + tagRepository: MockTagRepository(), + diveTypeRepository: MockDiveTypeRepository(), + diveRoleRepository: MockDiveRoleRepository(), + siteRepository: sites, + diveRepository: MockDiveRepository(), + tankPressureRepository: MockTankPressureRepository(), + courseRepository: MockCourseRepository(), + serviceRecordRepository: MockServiceRecordRepository(), + ); + }); + + test('geocodes an address-less site in the place name language', () async { + final server = FakeNominatim( + body: '{"address": {"country": "Suisse", "state": "Lucerne"}}', + ); + final importer = UddfEntityImporter(placeNameLanguage: 'fr'); + const data = UddfImportResult( + sites: [ + { + 'name': 'Hertenstein', + 'uddfId': 'site-1', + 'latitude': 47.027631, + 'longitude': 8.400640, + }, + ], + ); + + await server.run( + () => importer.import( + data: data, + selections: const UddfImportSelections(sites: {0}), + repositories: repos, + diverId: 'diver-1', + ), + ); + + expect(server.requestedUris, isNotEmpty); + for (final uri in server.requestedUris) { + expect(uri.queryParameters['accept-language'], 'fr'); + } + final site = + verify(sites.createSite(captureAny)).captured.single as DiveSite; + expect(site.country, 'Suisse'); + }); +} diff --git a/test/features/dive_sites/presentation/pages/site_edit_language_test.dart b/test/features/dive_sites/presentation/pages/site_edit_language_test.dart new file mode 100644 index 0000000000..aa73430e50 --- /dev/null +++ b/test/features/dive_sites/presentation/pages/site_edit_language_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/pages/site_edit_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Records the language every geocode was asked for. +class _RecordingLocationService implements LocationService { + final List languages = []; + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async { + languages.add(languageCode); + return const PlaceLookup(country: 'Schweiz', region: 'Luzern'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// A settings notifier that starts with German place names. +class _GermanSettings extends StateNotifier + implements SettingsNotifier { + _GermanSettings() : super(const AppSettings(placeNameLanguage: 'de')); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +List _divers() => [ + Diver( + id: 'd1', + name: 'Me', + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ), +]; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + testWidgets('seeding a new site geocodes in the place name language', ( + tester, + ) async { + final location = _RecordingLocationService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + allDiversProvider.overrideWith((_) async => _divers()), + shareByDefaultProvider.overrideWith((_) async => false), + settingsProvider.overrideWith((_) => _GermanSettings()), + locationServiceProvider.overrideWithValue(location), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SiteEditPage(initialLocation: GeoPoint(47.027631, 8.400640)), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(location.languages, ['de']); + expect(find.text('Schweiz'), findsOneWidget); + }); +} diff --git a/test/helpers/fake_nominatim.dart b/test/helpers/fake_nominatim.dart new file mode 100644 index 0000000000..1d2aaf8172 --- /dev/null +++ b/test/helpers/fake_nominatim.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Test doubles for the Nominatim HTTP path of `LocationService`. +/// +/// The geocoding paths talk to Nominatim through `dart:io HttpClient`, so the +/// only seam that does not require a real socket is [HttpOverrides]. Run the +/// code under test inside [FakeNominatim.run] and every request it makes is +/// captured for assertions. + +/// One canned HTTP exchange plus a record of what the service actually sent. +/// +/// The geocoding paths talk to Nominatim through `dart:io HttpClient`, so the +/// only seam that does not require a real socket is [HttpOverrides]. Every +/// request the service makes is captured here so the tests can assert on the +/// English pin (#214) that lives in the URI *and* in the request headers. +class FakeNominatim { + FakeNominatim({this.statusCode = 200, this.body = '{}', this.bodyFor}); + + final int statusCode; + final String body; + + /// When set, wins over [body] for the given request. + final String? Function(Uri uri)? bodyFor; + + String bodyForUri(Uri uri) => bodyFor?.call(uri) ?? body; + + final List requestedUris = []; + final List> requestHeaders = >[]; + int clientCloseCount = 0; + + Uri get lastUri => requestedUris.last; + Map get lastHeaders => requestHeaders.last; + + /// Run [body] with every `HttpClient` replaced by this fake server. + Future run(Future Function() action) => + HttpOverrides.runZoned>( + action, + createHttpClient: (SecurityContext? _) => FakeHttpClient(this), + ); +} + +class FakeHttpClient implements HttpClient { + FakeHttpClient(this._server); + + final FakeNominatim _server; + + @override + String? userAgent; + + @override + Future getUrl(Uri url) async { + _server.requestedUris.add(url); + return FakeHttpClientRequest(url, _server); + } + + @override + void close({bool force = false}) => _server.clientCloseCount++; + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class ThrowingHttpClient implements HttpClient { + @override + String? userAgent; + + @override + Future getUrl(Uri url) async { + throw const SocketException('offline'); + } + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class FakeHttpClientRequest implements HttpClientRequest { + FakeHttpClientRequest(this.uri, this._server); + + final FakeNominatim _server; + + @override + final Uri uri; + + @override + final HttpHeaders headers = FakeHttpHeaders(); + + @override + Future close() async { + _server.requestHeaders.add((headers as FakeHttpHeaders).values); + return FakeHttpClientResponse(_server.statusCode, _server.bodyForUri(uri)); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class FakeHttpHeaders implements HttpHeaders { + final Map values = {}; + + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) { + values[name.toLowerCase()] = '$value'; + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +class FakeHttpClientResponse extends Stream> + implements HttpClientResponse { + FakeHttpClientResponse(this.statusCode, this._body); + + @override + final int statusCode; + + final String _body; + + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return Stream>.value(utf8.encode(_body)).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} From 30f057ccd70bb136c45df9ca6bfbc24c26c17bf9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:28:46 -0400 Subject: [PATCH 060/122] feat(sites): fill-empty merge rule and repository patch for location details (#1187) --- .../repositories/site_repository_impl.dart | 58 ++++++++++++ .../domain/services/site_location_merge.dart | 52 +++++++++++ ...repository_fill_missing_location_test.dart | 91 +++++++++++++++++++ .../services/site_location_merge_test.dart | 87 ++++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 lib/features/dive_sites/domain/services/site_location_merge.dart create mode 100644 test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart create mode 100644 test/features/dive_sites/domain/services/site_location_merge_test.dart diff --git a/lib/features/dive_sites/data/repositories/site_repository_impl.dart b/lib/features/dive_sites/data/repositories/site_repository_impl.dart index 834b235e00..9dcbcbcac6 100644 --- a/lib/features/dive_sites/data/repositories/site_repository_impl.dart +++ b/lib/features/dive_sites/data/repositories/site_repository_impl.dart @@ -7,10 +7,12 @@ import 'package:submersion/core/data/visibility/visibility_filter.dart'; import 'package:submersion/core/database/database.dart'; import 'package:submersion/core/performance/perf_timer.dart'; import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/sync/sync_event_bus.dart'; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart' as domain; +import 'package:submersion/features/dive_sites/domain/services/site_location_merge.dart'; import 'package:submersion/features/media/data/repositories/media_repository.dart'; import 'package:submersion/features/media_store/data/media_deletion_coordinator.dart'; import 'package:submersion/features/media_store/data/media_transfer_queue_repository.dart'; @@ -233,6 +235,62 @@ class SiteRepository { /// /// Used by the UDDF importer to persist columns that do not flow through /// the [domain.DiveSite] entity (e.g. MacDive waterType). + /// Fills whichever of country, region, city and body of water are still + /// empty on [siteId] from [found], leaving every other column untouched + /// (issue #1187). Returns true when a column was written. The row is + /// marked pending for sync only when something changed. + Future fillMissingLocationDetails( + String siteId, + PlaceLookup found, + ) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + final changed = await _db.transaction(() async { + final row = await (_db.select( + _db.diveSites, + )..where((t) => t.id.equals(siteId))).getSingleOrNull(); + if (row == null) return false; + + final merged = mergeMissingLocationDetails( + current: SiteLocationDetails.ofSite(_mapRowToSite(row)), + found: found, + ); + if (merged == null) return false; + + Value column(String? value) => + value == null ? const Value.absent() : Value(value); + await (_db.update( + _db.diveSites, + )..where((t) => t.id.equals(siteId))).write( + DiveSitesCompanion( + country: column(merged.country), + region: column(merged.region), + city: column(merged.city), + bodyOfWater: column(merged.bodyOfWater), + updatedAt: Value(now), + ), + ); + return true; + }); + if (!changed) return false; + + await _syncRepository.markRecordPending( + entityType: 'diveSites', + recordId: siteId, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + return true; + } catch (e, stackTrace) { + _log.error( + 'Failed to fill location details for site: $siteId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Only columns set on [patch] are written; others are left untouched. /// Marks the row pending for sync. Future applyImportedMetadata( diff --git a/lib/features/dive_sites/domain/services/site_location_merge.dart b/lib/features/dive_sites/domain/services/site_location_merge.dart new file mode 100644 index 0000000000..8fced628b3 --- /dev/null +++ b/lib/features/dive_sites/domain/services/site_location_merge.dart @@ -0,0 +1,52 @@ +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +/// The four site columns a reverse geocode can fill. +class SiteLocationDetails { + const SiteLocationDetails({ + this.country, + this.region, + this.city, + this.bodyOfWater, + }); + + factory SiteLocationDetails.ofSite(DiveSite site) => SiteLocationDetails( + country: site.country, + region: site.region, + city: site.city, + bodyOfWater: site.bodyOfWater, + ); + + final String? country; + final String? region; + final String? city; + final String? bodyOfWater; + + bool get isEmpty => + country == null && region == null && city == null && bodyOfWater == null; +} + +bool _isBlank(String? value) => value == null || value.trim().isEmpty; + +/// The single home of the "only fill empty fields" rule (issue #1187). +/// +/// Returns the values to write, with null for every field that must not +/// change, or null when nothing should change. A field is filled only when +/// [current] is blank and [found] has a non-blank value for it; manual edits +/// and deliberate clears are never overwritten here. The lookup's locality +/// maps to the site's city column. +SiteLocationDetails? mergeMissingLocationDetails({ + required SiteLocationDetails current, + required PlaceLookup found, +}) { + String? fill(String? existing, String? candidate) => + _isBlank(existing) && !_isBlank(candidate) ? candidate!.trim() : null; + + final merged = SiteLocationDetails( + country: fill(current.country, found.country), + region: fill(current.region, found.region), + city: fill(current.city, found.locality), + bodyOfWater: fill(current.bodyOfWater, found.bodyOfWater), + ); + return merged.isEmpty ? null : merged; +} diff --git a/test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart b/test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart new file mode 100644 index 0000000000..0dfa17b0b3 --- /dev/null +++ b/test/features/dive_sites/data/repositories/site_repository_fill_missing_location_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart' show AppDatabase; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late SiteRepository sites; + + setUp(() async { + db = await setUpTestDatabase(); + sites = SiteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + const found = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', + ); + + Future clearSyncRecords(String siteId) => + (db.delete(db.syncRecords)..where((t) => t.recordId.equals(siteId))).go(); + + Future pendingCount(String siteId) async => (await (db.select( + db.syncRecords, + )..where((t) => t.recordId.equals(siteId))).get()).length; + + test('fills only the empty columns and reports a change', () async { + await sites.createSite( + const DiveSite( + id: 's1', + name: 'Hertenstein', + country: 'Schweiz', + rating: 4, + location: GeoPoint(47.027631, 8.400640), + ), + ); + + final changed = await sites.fillMissingLocationDetails('s1', found); + + expect(changed, isTrue); + final stored = await sites.getSiteById('s1'); + expect(stored!.country, 'Schweiz', reason: 'filled values are kept'); + expect(stored.region, 'Lucerne'); + expect(stored.city, 'Weggis'); + expect(stored.bodyOfWater, 'Lake Lucerne'); + expect(stored.rating, 4, reason: 'unrelated columns untouched'); + }); + + test('marks the site pending for sync when it changed', () async { + await sites.createSite(const DiveSite(id: 's2', name: 'n')); + await clearSyncRecords('s2'); + + await sites.fillMissingLocationDetails('s2', found); + + expect(await pendingCount('s2'), greaterThan(0)); + }); + + test('writes nothing and reports no change when all filled', () async { + await sites.createSite( + const DiveSite( + id: 's3', + name: 'n', + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + ), + ); + final before = await sites.getSiteById('s3'); + await clearSyncRecords('s3'); + + final changed = await sites.fillMissingLocationDetails('s3', found); + + expect(changed, isFalse); + expect(await sites.getSiteById('s3'), before); + expect(await pendingCount('s3'), 0, reason: 'no write, no sync record'); + }); + + test('returns false for an unknown site', () async { + expect(await sites.fillMissingLocationDetails('nope', found), isFalse); + }); +} diff --git a/test/features/dive_sites/domain/services/site_location_merge_test.dart b/test/features/dive_sites/domain/services/site_location_merge_test.dart new file mode 100644 index 0000000000..e33bb5b3cf --- /dev/null +++ b/test/features/dive_sites/domain/services/site_location_merge_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_merge.dart'; + +void main() { + const found = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', + ); + + test('fills every empty field', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(), + found: found, + ); + expect(merged, isNotNull); + expect(merged!.country, 'Switzerland'); + expect(merged.region, 'Lucerne'); + expect(merged.city, 'Weggis'); + expect(merged.bodyOfWater, 'Lake Lucerne'); + }); + + test('leaves filled fields alone and returns only the empty ones', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(country: 'Schweiz', region: 'Luzern'), + found: found, + ); + expect(merged!.country, isNull); + expect(merged.region, isNull); + expect(merged.city, 'Weggis'); + expect(merged.bodyOfWater, 'Lake Lucerne'); + }); + + test('treats whitespace-only as empty', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(city: ' '), + found: const PlaceLookup(locality: 'Weggis'), + ); + expect(merged!.city, 'Weggis'); + }); + + test('ignores blank found values', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(), + found: const PlaceLookup(country: '', locality: ' '), + ); + expect(merged, isNull); + }); + + test('returns null when every field is already filled', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails( + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + ), + found: found, + ); + expect(merged, isNull); + }); + + test('returns null when the lookup found nothing', () { + final merged = mergeMissingLocationDetails( + current: const SiteLocationDetails(), + found: const PlaceLookup.empty(), + ); + expect(merged, isNull); + }); + + test('ofSite reads the four location columns', () { + const site = DiveSite( + id: 's', + name: 'n', + country: 'Switzerland', + city: 'Weggis', + ); + final details = SiteLocationDetails.ofSite(site); + expect(details.country, 'Switzerland'); + expect(details.region, isNull); + expect(details.city, 'Weggis'); + expect(details.bodyOfWater, isNull); + }); +} From c34b94672c6f7b792d04ba7dc2c5ad7fbea3cf99 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:31:00 -0400 Subject: [PATCH 061/122] test(buddies): stop leaking FlutterError.onError in the detail page tests Addresses review feedback on #1294. The overflow-tolerating block captured FlutterError.onError's output but then assigned FlutterError.presentError, which is not a restore: testWidgets installs its own reporter on that process-global, so the assignment replaced the reporter that routes framework errors into the test's failure report with a plain printer. It also only ran on the happy path, so a test failing before that line leaked a swallowing handler into every later test in the isolate. That is the mechanism behind the "A test overrode FlutterError.onError but either failed to return it to its original state" assertion seen while these tests were still red. Both tests now share a helper that captures the previous handler and restores it from addTearDown, so it runs even on an early failure. The filter was also narrowed: only the RenderFlex overflow this page produces at phone widths is swallowed (verified: 69 pixels on the right at 390x844), and every other framework error is forwarded to the previous handler and still fails the test. The pre-existing bottomTime test carried the identical pattern and runs before the new one in the same isolate, so its leak would have been what the new test captured as its "previous" handler. Both are fixed. --- .../pages/buddy_detail_page_test.dart | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart index 990c231b28..acd3c6c227 100644 --- a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart +++ b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart @@ -13,6 +13,23 @@ import 'package:submersion/l10n/arb/app_localizations.dart'; import '../../../../helpers/mock_providers.dart'; +/// Silences the RenderFlex overflow this page produces at phone widths while +/// still surfacing every other framework error. +/// +/// `FlutterError.onError` is process-global and `testWidgets` installs its own +/// reporter on it, so the previous handler is captured and restored rather than +/// assuming `FlutterError.presentError`. The restore is registered with +/// `addTearDown` so it runs even when the test fails before reaching the end, +/// which would otherwise leak a swallowing handler into later tests. +void _ignoreOverflowErrors() { + final previousOnError = FlutterError.onError; + addTearDown(() => FlutterError.onError = previousOnError); + FlutterError.onError = (details) { + if (details.exception.toString().contains('overflowed')) return; + previousOnError?.call(details); + }; +} + void main() { group('BuddyDetailPage desktop redirect', () { final buddy = Buddy( @@ -184,11 +201,8 @@ void main() { ), ), ); - // Tolerate overflow errors in test layout - final errors = []; - FlutterError.onError = (d) => errors.add(d); + _ignoreOverflowErrors(); await tester.pumpAndSettle(); - FlutterError.onError = FlutterError.presentError; // Should show bottomTime formatted as minutes in dive history expect(find.text('45min'), findsOneWidget); @@ -246,11 +260,8 @@ void main() { ), ), ); - // Tolerate overflow errors in test layout - final errors = []; - FlutterError.onError = (d) => errors.add(d); + _ignoreOverflowErrors(); await tester.pumpAndSettle(); - FlutterError.onError = FlutterError.presentError; expect( find.text(DateFormat.yMMMd().format(dives.first.dateTime)), From 5688bae63ac17aa37b5486510b90a6ab92db6291 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:33:35 -0400 Subject: [PATCH 062/122] feat(sites): fill town and body of water from every coordinate source (#1187) --- .../presentation/pages/site_edit_page.dart | 56 +++--- .../widgets/location_picker_map.dart | 15 +- .../widgets/geofence_editor_sheet.dart | 3 +- .../pages/site_edit_fill_location_test.dart | 159 ++++++++++++++++++ 4 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart diff --git a/lib/features/dive_sites/presentation/pages/site_edit_page.dart b/lib/features/dive_sites/presentation/pages/site_edit_page.dart index 6d97f015c0..f5ae26c39c 100644 --- a/lib/features/dive_sites/presentation/pages/site_edit_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_edit_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; @@ -16,6 +17,7 @@ import 'package:submersion/features/dive_log/presentation/widgets/environment_en import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_merge.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; import 'package:submersion/features/dive_sites/presentation/widgets/edit_sections/access_safety_section.dart'; import 'package:submersion/features/dive_sites/presentation/widgets/edit_sections/dive_info_section.dart'; @@ -219,16 +221,42 @@ class _SiteEditPageState extends ConsumerState { if (!mounted) return; setState(() { _isApplyingInitialValues = true; - if (_countryController.text.isEmpty && result.country != null) { - _countryController.text = result.country!; - } - if (_regionController.text.isEmpty && result.region != null) { - _regionController.text = result.region!; - } + _applyPlaceLookup(result, overwrite: false); _isApplyingInitialValues = false; }); } + /// Writes [lookup] into the country, region, city and body of water + /// fields. With [overwrite] false only empty fields change (the rule lives + /// in [mergeMissingLocationDetails]); with it true every found value + /// replaces the current one. Returns whether any field changed. Callers + /// decide whether that dirties the form. + bool _applyPlaceLookup(PlaceLookup lookup, {required bool overwrite}) { + final current = overwrite + ? const SiteLocationDetails() + : SiteLocationDetails( + country: _countryController.text, + region: _regionController.text, + city: _cityController.text, + bodyOfWater: _bodyOfWaterController.text, + ); + final merged = mergeMissingLocationDetails(current: current, found: lookup); + if (merged == null) return false; + + var changed = false; + void set(TextEditingController controller, String? value) { + if (value == null || controller.text == value) return; + controller.text = value; + changed = true; + } + + set(_countryController, merged.country); + set(_regionController, merged.region); + set(_cityController, merged.city); + set(_bodyOfWaterController, merged.bodyOfWater); + return changed; + } + @override void dispose() { _altitudeLookupDebounce?.cancel(); @@ -1381,13 +1409,7 @@ class _SiteEditPageState extends ConsumerState { _latitudeController.text = result.latitude.toStringAsFixed(6); _longitudeController.text = result.longitude.toStringAsFixed(6); _hasChanges = true; - - if (_countryController.text.isEmpty && result.country != null) { - _countryController.text = result.country!; - } - if (_regionController.text.isEmpty && result.region != null) { - _regionController.text = result.region!; - } + _applyPlaceLookup(result.place, overwrite: false); }); if (mounted) { @@ -1431,13 +1453,7 @@ class _SiteEditPageState extends ConsumerState { _latitudeController.text = result.latitude.toStringAsFixed(6); _longitudeController.text = result.longitude.toStringAsFixed(6); _hasChanges = true; - - if (_countryController.text.isEmpty && result.country != null) { - _countryController.text = result.country!; - } - if (_regionController.text.isEmpty && result.region != null) { - _regionController.text = result.region!; - } + _applyPlaceLookup(result.place, overwrite: false); }); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/dive_sites/presentation/widgets/location_picker_map.dart b/lib/features/dive_sites/presentation/widgets/location_picker_map.dart index c6efbe38f4..41bb14e947 100644 --- a/lib/features/dive_sites/presentation/widgets/location_picker_map.dart +++ b/lib/features/dive_sites/presentation/widgets/location_picker_map.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; import 'package:submersion/core/services/location_service.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; @@ -16,16 +17,14 @@ import 'package:submersion/features/settings/presentation/providers/settings_pro class PickedLocation { final double latitude; final double longitude; - final String? country; - final String? region; - final String? locality; + + /// What the coordinates reverse-geocoded to. + final PlaceLookup place; const PickedLocation({ required this.latitude, required this.longitude, - this.country, - this.region, - this.locality, + required this.place, }); } @@ -108,9 +107,7 @@ class _LocationPickerMapState extends ConsumerState { PickedLocation( latitude: _selectedLocation!.latitude, longitude: _selectedLocation!.longitude, - country: result.country, - region: result.region, - locality: result.locality, + place: result, ), ); } diff --git a/lib/features/equipment/presentation/widgets/geofence_editor_sheet.dart b/lib/features/equipment/presentation/widgets/geofence_editor_sheet.dart index 7ab6ec219a..b84647a48f 100644 --- a/lib/features/equipment/presentation/widgets/geofence_editor_sheet.dart +++ b/lib/features/equipment/presentation/widgets/geofence_editor_sheet.dart @@ -82,7 +82,8 @@ class _GeofenceEditorSheetState extends ConsumerState<_GeofenceEditorSheet> { _latitude = result.latitude; _longitude = result.longitude; if (_labelController.text.isEmpty) { - _labelController.text = result.locality ?? result.region ?? ''; + _labelController.text = + result.place.locality ?? result.place.region ?? ''; } }); } diff --git a/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart b/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart new file mode 100644 index 0000000000..8138ade7e3 --- /dev/null +++ b/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/pages/site_edit_page.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Every coordinate source (current location, map pick, seeding from a +/// dive) answers with the same place, so the tests can tell which fields +/// the form chose to fill. +class _FakeLocationService implements LocationService { + _FakeLocationService(this.place); + + final PlaceLookup place; + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async => place; + + @override + Future getCurrentLocation({ + bool includeGeocoding = true, + Duration timeout = const Duration(seconds: 15), + String languageCode = LocationService.defaultLanguageCode, + }) async => LocationResult( + latitude: 47.027631, + longitude: 8.400640, + accuracy: 5, + country: place.country, + region: place.region, + locality: place.locality, + bodyOfWater: place.bodyOfWater, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const _weggis = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', +); + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpEditor( + WidgetTester tester, { + String? siteId, + GeoPoint? initialLocation, + PlaceLookup place = _weggis, + DiveSite? seeded, + }) async { + tester.view.physicalSize = const Size(900, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + allDiversProvider.overrideWith((_) async => const []), + shareByDefaultProvider.overrideWith((_) async => false), + validatedCurrentDiverIdProvider.overrideWith((_) async => null), + if (seeded != null) + siteProvider(seeded.id).overrideWith((_) async => seeded), + locationServiceProvider.overrideWithValue( + _FakeLocationService(place), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SiteEditPage( + siteId: siteId, + initialLocation: initialLocation, + embedded: true, + onSaved: (_) {}, + onCancel: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('Use my location fills town and body of water', (tester) async { + await pumpEditor(tester); + + // The Location group rests collapsed; expand it to reach the GPS row. + await tester.tap(find.text('Add GPS position or altitude')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use My Location')); + await tester.pumpAndSettle(); + + expect(find.text('Weggis'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsOneWidget); + expect(find.text('Switzerland'), findsOneWidget); + }); + + testWidgets('Use my location never overwrites a filled field', ( + tester, + ) async { + final repo = SiteRepository(); + final seeded = await repo.createSite( + const DiveSite(id: '', name: 'Hertenstein', city: 'Hertenstein'), + ); + await pumpEditor(tester, siteId: seeded.id, seeded: seeded); + + // The Location group rests collapsed; expand it to reach the GPS row. + await tester.tap(find.text('Add GPS position or altitude')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use My Location')); + await tester.pumpAndSettle(); + + expect(find.text('Hertenstein'), findsWidgets); + expect(find.text('Weggis'), findsNothing); + expect(find.text('Lake Lucerne'), findsOneWidget); + }); + + testWidgets('seeding from a dive fills town and body of water', ( + tester, + ) async { + await pumpEditor( + tester, + initialLocation: const GeoPoint(47.027631, 8.400640), + ); + + expect(find.text('Weggis'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsOneWidget); + }); +} From c9637c91de3315e29775c2d3dc976c0c5544c0ab Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:36:32 -0400 Subject: [PATCH 063/122] fix(import): register dive computers for file-imported dives (#1288) A logbook built from a file import (UDDF, FIT, MacDive, Shearwater export, CSV) named a dive computer on every dive's Details card, yet Dives > Filter still read "No dive computers registered" and offered nothing to filter by. The importer wrote only the `dive_computer_model`/`_serial`/`_firmware` display snapshots onto each dive. It never created a `dive_computers` row and never stamped `dives.computer_id`, which is what the filter, the statistics SQL, and "View dives from this computer" all read. This is a separate cause from #1064 (fixed in v1.7.5), which was the serial-keyed filter; the two share a symptom string. Registration keys on the serial when the file supplies one, matching the download path, and falls back to the model string when it does not, which is all most UDDF/MacDive/CSV exports offer. A serial-less import never adopts a serial-bearing row: two units of one model are common, and collapsing them would misattribute dives irreversibly. New rows land on a deterministic UUIDv5 derived from the normalized identity, following the `account_identity.dart` precedent. Every device derives the same primary key, so sync's upsert-by-id merges rather than unioning, and no unique index is needed (a unique constraint on a replicated table makes an inbound sync insert throw instead of merge). `Dive.computerId` stays a read-only projection: `createDive` and `updateDive` still omit it, and file import joins the download, consolidation, split, and reparse paths in setting it with explicit intent through `attributeDiveToComputer`. A `beforeOpen` self-heal registers computers for logbooks imported before this shipped, so existing users need not re-import. It is local-only and idempotent, with no HLC bump and nothing marked pending, because every device derives the same rows independently. It skips tombstoned ids and any dive carrying a `dive_computer` provenance row, so a deliberate `deleteComputer` still sticks and a downloaded dive's snapshots cannot conjure a phantom device. Registration and attribution are both best-effort: attribution is cosmetic next to the dives themselves, so a registry failure leaves that device unattributed rather than costing the user the whole import. Also plumbs UDDF's `` through to the registered row, which previously stopped at the equipment list and never reached dive attribution. --- lib/core/database/database.dart | 16 + .../database/imported_computer_backfill.dart | 194 +++++++++ .../database/imported_computer_identity.dart | 124 ++++++ .../export/uddf/uddf_full_import_service.dart | 9 + .../export/uddf/uddf_import_parsers.dart | 22 + .../export/uddf/uddf_import_service.dart | 9 + .../data/services/uddf_entity_importer.dart | 112 +++++ .../dive_computer_repository_impl.dart | 156 +++++++ .../data/adapters/universal_adapter.dart | 2 + .../imported_dive_computer_backfill_test.dart | 396 ++++++++++++++++++ .../uddf/uddf_computer_manufacturer_test.dart | 137 ++++++ ...y_importer_computer_registration_test.dart | 310 ++++++++++++++ .../dive_computer_repository_impl_test.dart | 241 +++++++++++ 13 files changed, 1728 insertions(+) create mode 100644 lib/core/database/imported_computer_backfill.dart create mode 100644 lib/core/database/imported_computer_identity.dart create mode 100644 test/core/database/imported_dive_computer_backfill_test.dart create mode 100644 test/core/services/export/uddf/uddf_computer_manufacturer_test.dart create mode 100644 test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 92124f1a74..5ce86d7bff 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:drift/drift.dart'; +import 'package:submersion/core/database/imported_computer_backfill.dart'; import 'package:submersion/core/database/performance_indexes.dart'; import 'package:submersion/core/database/tag_uniqueness.dart'; import 'package:submersion/core/constants/enums.dart'; @@ -4485,6 +4486,15 @@ class AppDatabase extends _$AppDatabase { /// Test-only hook exercising the #1064 attribution self-heal directly. Future backfillDiveComputerIdsForTest() => _backfillDiveComputerIds(); + /// Register the dive computers that file-imported dives name (issue + /// #1288). Body lives in `imported_computer_backfill.dart`. + Future _backfillImportedDiveComputers() => + backfillImportedDiveComputers(this); + + /// Test-only hook exercising the #1288 registration self-heal directly. + Future backfillImportedDiveComputersForTest() => + _backfillImportedDiveComputers(); + /// Copy each buddy's inline certification into a certifications row owned by /// that buddy (issue #553). Invoked from the onUpgrade blocks only (v109 /// expand + the v110 contract safety-net), NEVER the beforeOpen backstop -- @@ -8899,6 +8909,12 @@ class AppDatabase extends _$AppDatabase { // bump). Also AFTER ensurePerformanceIndexes, for the same reason as // the backfill above. await _backfillDiveComputerIds(); + + // Data self-heal (issue #1288): register the computers that + // file-imported dives name, so they reach the filter at all. AFTER + // the #1064 heal above, which resolves the same column from the + // stronger download-derived signal. + await _backfillImportedDiveComputers(); }, ); } diff --git a/lib/core/database/imported_computer_backfill.dart b/lib/core/database/imported_computer_backfill.dart new file mode 100644 index 0000000000..2b88d8478c --- /dev/null +++ b/lib/core/database/imported_computer_backfill.dart @@ -0,0 +1,194 @@ +import 'package:drift/drift.dart'; + +import 'package:submersion/core/database/imported_computer_identity.dart'; + +/// Register the dive computers that file-imported dives name, and attribute +/// those dives to them (issue #1288). +/// +/// A file import writes only the `dive_computer_model`/`_serial` display +/// snapshots; before #1288 it created no `dive_computers` row and never +/// stamped `computer_id`. The filter, the statistics SQL, and "View dives +/// from this computer" all read the registry, so a logbook built from files +/// named a computer on every dive and still reported "No dive computers +/// registered". +/// +/// The #1064 self-heal cannot reach these dives: it adopts `computer_id` from +/// `dive_data_sources`, which a file import also left null. Hence a second +/// pass keyed on the snapshots instead. +/// +/// Local-only and idempotent: no HLC bump, nothing marked pending. New rows +/// land on a deterministic id ([importedDiveComputerId]), so every device in a +/// synced fleet derives the same primary key independently and converges +/// without any of them pushing duplicates. An existing row for the same +/// hardware is adopted rather than shadowed, using the same rule the import +/// path applies ([matchImportedComputer]). +/// +/// Must run after the #1064 heal, so a dive that can be attributed from its +/// data source (the stronger, download-derived signal) is already resolved +/// and skipped here. +/// +/// Only `dives.computer_id` is stamped, not `dive_data_sources.computer_id`: +/// a dive can carry several sources, and no snapshot on the source row is +/// reliable enough to say which of them this device produced. +Future backfillImportedDiveComputers(DatabaseConnectionUser db) async { + // PRAGMA-guarded like every other self-heal helper: beforeOpen runs for + // every open, including minimal old-schema fixtures and databases caught + // mid-upgrade. PRAGMA table_info returns empty for a missing table, so + // probing the columns covers both cases. + Future> columnsOf(String table) async { + final rows = await db.customSelect("PRAGMA table_info('$table')").get(); + return rows.map((c) => c.read('name')).toSet(); + } + + final diveCols = await columnsOf('dives'); + if (!diveCols.containsAll({ + 'computer_id', + 'dive_computer_model', + 'dive_computer_serial', + 'diver_id', + })) { + return; + } + final computerCols = await columnsOf('dive_computers'); + if (!computerCols.containsAll({ + 'id', + 'diver_id', + 'name', + 'manufacturer', + 'model', + 'serial_number', + 'dive_count', + 'is_favorite', + 'notes', + 'created_at', + 'updated_at', + })) { + return; + } + final sourceCols = await columnsOf('dive_data_sources'); + if (!sourceCols.containsAll({'dive_id', 'source_format'})) return; + + // Downloaded dives carry the same model/serial snapshots, written by + // importProfile. Excluding them is what stops a deleted device from coming + // back as a phantom: deleteComputer nulls dives.computer_id but leaves the + // snapshots, and its _backfillProvenanceSnapshots pass guarantees every + // orphaned dive keeps a `dive_computer` source row to recognize it by. + const notADownload = + "NOT EXISTS (SELECT 1 FROM dive_data_sources s " + "WHERE s.dive_id = dives.id AND s.source_format = 'dive_computer')"; + + final identities = await db.customSelect(''' + SELECT DISTINCT diver_id, dive_computer_model, dive_computer_serial + FROM dives + WHERE computer_id IS NULL + AND TRIM(COALESCE(dive_computer_model, '')) <> '' + AND $notADownload + ''').get(); + if (identities.isEmpty) return; + + // A file-imported computer the user deleted keeps its snapshots too, and + // its row was registered at the deterministic id, so re-minting would + // resurrect a tombstoned primary key: the peer that applied the delete + // would delete it again on the next sync, forever. + final tombstoned = await _tombstonedComputerIds(db); + + // The registry is small (a handful of devices per library), so it is read + // once into memory: the model comparison collapses internal whitespace, + // which SQLite cannot express. + var candidates = await _candidates(db); + + for (final identity in identities) { + final model = identity.read('dive_computer_model'); + final serial = identity.read('dive_computer_serial'); + final diverId = identity.read('diver_id'); + + var computerId = matchImportedComputer( + model: model, + serialNumber: serial, + diverId: diverId, + candidates: candidates, + )?.id; + + if (computerId == null) { + final derivedId = importedDiveComputerId( + model: model, + serialNumber: serial, + diverId: diverId, + ); + if (tombstoned.contains(derivedId)) continue; + + computerId = derivedId; + final trimmedModel = model.trim(); + final trimmedSerial = serial?.trim(); + final now = DateTime.now().millisecondsSinceEpoch; + await db.customStatement( + 'INSERT OR IGNORE INTO dive_computers ' + '(id, diver_id, name, model, serial_number, dive_count, ' + 'is_favorite, notes, created_at, updated_at) ' + "VALUES (?, ?, ?, ?, ?, 0, 0, '', ?, ?)", + [ + computerId, + diverId, + trimmedModel, + trimmedModel, + (trimmedSerial?.isEmpty ?? true) ? null : trimmedSerial, + now, + now, + ], + ); + // Re-read so a later identity that normalizes onto this same device + // adopts it instead of racing to insert it again. + candidates = await _candidates(db); + } + + // `IS` rather than `=` so the NULL diver/serial groups match too. The + // download exclusion is repeated here: without it this would claim the + // downloaded dives that share the identity but were filtered out above. + await db.customStatement( + 'UPDATE dives SET computer_id = ? ' + 'WHERE computer_id IS NULL AND diver_id IS ? ' + 'AND dive_computer_model IS ? AND dive_computer_serial IS ? ' + 'AND $notADownload', + [computerId, diverId, model, serial], + ); + } +} + +/// Ids of dive computers this library has deleted. +/// +/// Empty when the schema predates the deletion log, which also predates any +/// delete it could have recorded. +Future> _tombstonedComputerIds(DatabaseConnectionUser db) async { + final cols = await db.customSelect("PRAGMA table_info('deletion_log')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.containsAll({'entity_type', 'record_id'})) return const {}; + + final rows = await db + .customSelect( + "SELECT record_id FROM deletion_log WHERE entity_type = 'diveComputers'", + ) + .get(); + return rows.map((r) => r.read('record_id')).toSet(); +} + +Future> _candidates( + DatabaseConnectionUser db, +) async { + final rows = await db + .customSelect( + 'SELECT id, diver_id, manufacturer, model, serial_number ' + 'FROM dive_computers ORDER BY updated_at DESC, id', + ) + .get(); + return rows + .map( + (row) => ImportedComputerCandidate( + id: row.read('id'), + diverId: row.read('diver_id'), + manufacturer: row.read('manufacturer'), + model: row.read('model'), + serialNumber: row.read('serial_number'), + ), + ) + .toList(); +} diff --git a/lib/core/database/imported_computer_identity.dart b/lib/core/database/imported_computer_identity.dart new file mode 100644 index 0000000000..eebc291efc --- /dev/null +++ b/lib/core/database/imported_computer_identity.dart @@ -0,0 +1,124 @@ +import 'package:uuid/uuid.dart'; + +/// Namespace for deterministic dive computer ids derived from file imports. +/// Frozen: every device must derive the same id from the same device +/// identity, so changing this would fork the registry across the fleet. +const String kImportedDiveComputerNamespace = + '70658227-40eb-49bf-b86f-66dc22323d4b'; + +/// Normalize a computer identity fragment for comparison and id derivation. +/// +/// Trims, collapses runs of internal whitespace, and lowercases, so +/// `' PERDIX 2 '` and `'Perdix 2'` are one device rather than two. +String normalizeComputerIdentityPart(String? value) { + if (value == null) return ''; + return value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); +} + +/// A registered dive computer, reduced to the fields the import match needs. +/// +/// Lets the matching rule live in one place: the repository builds these from +/// domain entities, the `beforeOpen` self-heal from raw rows. +class ImportedComputerCandidate { + const ImportedComputerCandidate({ + required this.id, + this.diverId, + this.manufacturer, + this.model, + this.serialNumber, + }); + + final String id; + final String? diverId; + final String? manufacturer; + final String? model; + final String? serialNumber; +} + +/// The already-registered computer a file-imported dive belongs to, if any. +/// +/// A file offers a weaker identity than a download does, so the rule has two +/// tiers: +/// +/// - With a serial, match on the serial alone. The file's model spelling must +/// not defeat it, exactly as on the download path. +/// - Without one, match a candidate that also has no serial and whose model, +/// or whose manufacturer + model, normalizes to the same string. Comparing +/// the combined form is what lets a file's `'Shearwater Perdix'` find a +/// downloaded row stored as manufacturer `'Shearwater'`, model `'Perdix'`. +/// +/// A serial-bearing candidate is deliberately never adopted by a serial-less +/// import: two units of one model are common, and collapsing them would +/// misattribute dives with no way to undo it. +/// +/// [candidates] must already be ordered by preference (most recently updated +/// first) so every device resolves a tie the same way. +ImportedComputerCandidate? matchImportedComputer({ + required String model, + String? serialNumber, + String? diverId, + required Iterable candidates, +}) { + final normalizedModel = normalizeComputerIdentityPart(model); + if (normalizedModel.isEmpty) return null; + final normalizedSerial = normalizeComputerIdentityPart(serialNumber); + final normalizedDiver = normalizeComputerIdentityPart(diverId); + + for (final candidate in candidates) { + if (normalizedDiver.isNotEmpty && + normalizeComputerIdentityPart(candidate.diverId) != normalizedDiver) { + continue; + } + final candidateSerial = normalizeComputerIdentityPart( + candidate.serialNumber, + ); + if (normalizedSerial.isNotEmpty) { + if (candidateSerial == normalizedSerial) return candidate; + continue; + } + if (candidateSerial.isNotEmpty) continue; + + final candidateModel = normalizeComputerIdentityPart(candidate.model); + final candidateManufacturer = normalizeComputerIdentityPart( + candidate.manufacturer, + ); + final candidateFullName = candidateManufacturer.isEmpty + ? candidateModel + : '$candidateManufacturer $candidateModel'; + if (candidateModel == normalizedModel || + candidateFullName == normalizedModel) { + return candidate; + } + } + return null; +} + +/// The deterministic id for a dive computer named by a file import. +/// +/// File imports register computers from two places that never see each +/// other: the import itself, and the `beforeOpen` self-heal that adopts +/// logbooks imported before registration existed. Both must land on the same +/// primary key, or a synced fleet ends up with one row per device for a +/// single physical computer; there is no merge action to clean that up. +/// +/// Deriving the id from the identity rather than minting a v4 makes sync's +/// upsert-by-id merge the rows instead of unioning them. This is also why +/// `dive_computers` needs no unique index: a unique constraint on a +/// replicated table would make an inbound sync insert throw rather than +/// merge. +/// +/// The serial is the strong key when the file supplies one. When it does +/// not, the model string carries the identity on its own, which is weaker +/// but is all most UDDF/MacDive/CSV exports offer. +String importedDiveComputerId({ + required String model, + String? serialNumber, + String? diverId, +}) { + final normalizedSerial = normalizeComputerIdentityPart(serialNumber); + final normalizedDiver = normalizeComputerIdentityPart(diverId); + final key = normalizedSerial.isNotEmpty + ? 'serial:$normalizedDiver|$normalizedSerial' + : 'model:$normalizedDiver|${normalizeComputerIdentityPart(model)}'; + return const Uuid().v5(kImportedDiveComputerNamespace, key); +} diff --git a/lib/core/services/export/uddf/uddf_full_import_service.dart b/lib/core/services/export/uddf/uddf_full_import_service.dart index 16497e9e5d..d2d1b81680 100644 --- a/lib/core/services/export/uddf/uddf_full_import_service.dart +++ b/lib/core/services/export/uddf/uddf_full_import_service.dart @@ -156,6 +156,9 @@ class UddfFullImportService { 'model': model ?? '', 'serial': serial ?? '', 'firmware': firmware ?? '', + 'manufacturer': + UddfImportParsers.getManufacturerName(computerElement) ?? + '', }; } } @@ -1290,6 +1293,9 @@ class UddfFullImportService { if (computer['firmware']?.isNotEmpty == true) { diveData['diveComputerFirmware'] = computer['firmware']; } + if (computer['manufacturer']?.isNotEmpty == true) { + diveData['diveComputerManufacturer'] = computer['manufacturer']; + } } } } @@ -1309,6 +1315,9 @@ class UddfFullImportService { if (computer['firmware']?.isNotEmpty == true) { diveData['diveComputerFirmware'] = computer['firmware']; } + if (computer['manufacturer']?.isNotEmpty == true) { + diveData['diveComputerManufacturer'] = computer['manufacturer']; + } } } } diff --git a/lib/core/services/export/uddf/uddf_import_parsers.dart b/lib/core/services/export/uddf/uddf_import_parsers.dart index 4702f4ddfe..923c37c389 100644 --- a/lib/core/services/export/uddf/uddf_import_parsers.dart +++ b/lib/core/services/export/uddf/uddf_import_parsers.dart @@ -138,6 +138,28 @@ class UddfImportParsers { : element?.innerText.trim(); } + /// Reads a dive computer's vendor from a `` element. + /// + /// UDDF nests the vendor as ``, alongside optional + /// address and contact children, so the nested `` is read first. + /// + /// The bare-text fallback keeps exporters that write + /// `Shearwater` working, but applies only when + /// the element has no child elements at all: `innerText` walks the whole + /// subtree, so a `` carrying only an address would otherwise + /// yield a vendor of "VancouverCanada". + static String? getManufacturerName(XmlElement computerElement) { + final manufacturer = computerElement + .findElements('manufacturer') + .firstOrNull; + if (manufacturer == null) return null; + final named = getElementText(manufacturer, 'name'); + if (named != null) return named; + if (manufacturer.childElements.isNotEmpty) return null; + final text = manufacturer.innerText.trim(); + return text.isEmpty ? null : text; + } + /// Maximum O2 cells the profile schema can hold (o2Sensor1..o2Sensor6). static const int maxO2Sensors = 6; diff --git a/lib/core/services/export/uddf/uddf_import_service.dart b/lib/core/services/export/uddf/uddf_import_service.dart index d410f6bc46..75bee3d92a 100644 --- a/lib/core/services/export/uddf/uddf_import_service.dart +++ b/lib/core/services/export/uddf/uddf_import_service.dart @@ -130,6 +130,9 @@ class UddfImportService { 'model': model ?? '', 'serial': serial ?? '', 'firmware': firmware ?? '', + 'manufacturer': + UddfImportParsers.getManufacturerName(computerElement) ?? + '', }; } } @@ -332,6 +335,9 @@ class UddfImportService { if (computer['firmware']?.isNotEmpty == true) { diveData['diveComputerFirmware'] = computer['firmware']; } + if (computer['manufacturer']?.isNotEmpty == true) { + diveData['diveComputerManufacturer'] = computer['manufacturer']; + } } } } @@ -351,6 +357,9 @@ class UddfImportService { if (computer['firmware']?.isNotEmpty == true) { diveData['diveComputerFirmware'] = computer['firmware']; } + if (computer['manufacturer']?.isNotEmpty == true) { + diveData['diveComputerManufacturer'] = computer['manufacturer']; + } } } } diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index 73b58d1bb7..3c3e9d4166 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart' show Value; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/imported_computer_identity.dart'; import 'package:submersion/core/database/database.dart' show DiveDataSourcesCompanion, DiveSitesCompanion, DivesCompanion; import 'package:submersion/core/services/export/export_service.dart'; @@ -19,6 +20,7 @@ import 'package:submersion/features/courses/data/repositories/course_repository. import 'package:submersion/features/courses/domain/entities/course.dart'; import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; @@ -72,6 +74,12 @@ class ImportRepositories { final TankPressureRepository tankPressureRepository; final CourseRepository courseRepository; + /// Optional for the same reason; when null, the dives keep their + /// `dive_computer_model`/`_serial` display snapshots but no + /// `dive_computers` row is registered and no attribution is stamped + /// (#1288). + final DiveComputerRepository? diveComputerRepository; + const ImportRepositories({ required this.tripRepository, required this.equipmentRepository, @@ -87,6 +95,7 @@ class ImportRepositories { required this.diveRepository, required this.tankPressureRepository, required this.courseRepository, + this.diveComputerRepository, }); } @@ -1283,6 +1292,69 @@ class UddfEntityImporter { // -- Dive import -- + /// Group key for the computer a parsed dive names, matching the identity + /// [DiveComputerRepository.findOrRegisterImportedComputer] dedupes on, so + /// two spellings of one device share a single registration. + /// + /// Null when the source names no model, which is the signal to leave the + /// dive unattributed rather than register a placeholder device. + String? _importedComputerKey(Map diveData) { + final model = normalizeComputerIdentityPart( + diveData['diveComputerModel'] as String?, + ); + if (model.isEmpty) return null; + final serial = normalizeComputerIdentityPart( + diveData['diveComputerSerial'] as String?, + ); + return serial.isNotEmpty ? 'serial:$serial' : 'model:$model'; + } + + /// Register every distinct computer the selected dives name, returning the + /// registry id for each [_importedComputerKey]. + /// + /// Runs once per import rather than per dive so a hundred dives off one + /// computer cost one registration lookup, not a hundred. + /// + /// Best-effort per device, mirroring `_relinkOrphanedRows`: attribution is + /// cosmetic next to the dives themselves, so a registry failure degrades + /// that one device to unattributed instead of costing the user the whole + /// import. The dive still keeps its `dive_computer_model` snapshot, which + /// is what the Details card renders. + Future> _registerImportedComputers( + List> items, + List selected, + String diverId, + DiveComputerRepository? repository, + ) async { + if (repository == null) return const {}; + + final idByKey = {}; + for (final i in selected) { + final diveData = items[i]; + final key = _importedComputerKey(diveData); + if (key == null || idByKey.containsKey(key)) continue; + + try { + final computer = await repository.findOrRegisterImportedComputer( + model: diveData['diveComputerModel'] as String, + manufacturer: diveData['diveComputerManufacturer'] as String?, + serialNumber: diveData['diveComputerSerial'] as String?, + firmwareVersion: diveData['diveComputerFirmware'] as String?, + diverId: diverId, + ); + if (computer != null) idByKey[key] = computer.id; + } catch (e, stackTrace) { + _log.error( + 'Failed to register imported dive computer for "$key"; ' + 'its dives stay unattributed', + error: e, + stackTrace: stackTrace, + ); + } + } + return idByKey; + } + Future<_DiveImportResult> _importDives( List> items, Set selected, @@ -1326,6 +1398,19 @@ class UddfEntityImporter { // at the same location into a single elevation request. final altitudeEnricher = DiveAltitudeEnricher(); + // Register the computers this batch names, once per distinct device, + // before any dive is written. The filter, the statistics SQL, and "View + // dives from this computer" all read the `dive_computers` registry + // rather than the per-dive display snapshots, so without this a + // file-only logbook shows a computer on every dive and still reports + // "No dive computers registered" (#1288). + final computerIdByKey = await _registerImportedComputers( + items, + sortedSelected, + diverId, + repos.diveComputerRepository, + ); + for (final i in sortedSelected) { if (cancelToken?.isCancelled ?? false) break; @@ -1599,6 +1684,32 @@ class UddfEntityImporter { } await repos.diveRepository.createDive(dive); + + // createDive's companion deliberately omits computer_id, so attribution + // has to be an explicit second write (#1288). + final computerKey = _importedComputerKey(diveData); + final computerId = computerKey == null + ? null + : computerIdByKey[computerKey]; + if (computerId != null) { + // Best-effort for the same reason as the registration above, and more + // pressingly: the dive is already committed, so throwing here would + // abort the loop and leave a half-imported logbook behind. + try { + await repos.diveComputerRepository?.attributeDiveToComputer( + diveId: diveId, + computerId: computerId, + ); + } catch (e, stackTrace) { + _log.error( + 'Failed to attribute imported dive $diveId to computer ' + '$computerId; the dive keeps its model snapshot only', + error: e, + stackTrace: stackTrace, + ); + } + } + await DiveEquipmentDefaulter().applyForImportedDive(dive); await ChecklistDiveLinker().applyForImportedDive(dive); await altitudeEnricher.applyForImportedDive(dive); @@ -1910,6 +2021,7 @@ class UddfEntityImporter { id: Value(_uuid.v4()), diveId: Value(diveId), isPrimary: const Value(true), + computerId: Value(computerId), computerModel: Value(diveData['diveComputerModel'] as String?), computerSerial: Value(diveData['diveComputerSerial'] as String?), sourceFileName: Value(sourceFileName), diff --git a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart index 72906b3fa8..876ed5b4f5 100644 --- a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart @@ -17,6 +17,7 @@ import 'package:submersion/core/database/database.dart' DiveProfile, DiveProfileEvent, TankPressureProfilesCompanion; +import 'package:submersion/core/database/imported_computer_identity.dart'; import 'package:submersion/core/matching/match_scorer.dart'; import 'package:submersion/core/utils/stream_debounce.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; @@ -1598,6 +1599,161 @@ class DiveComputerRepository { } } + /// Attribute a dive to a computer, with explicit intent. + /// + /// `Dive.computerId` is a read-only projection: the insert/update + /// companions deliberately omit the column so saving a dive never rewrites + /// attribution. Setting it therefore needs a deliberate write, which is + /// what the download, consolidation, split, and reparse paths do; file + /// import (#1288) joins them through here. + /// + /// Marks the dive pending so the restored link syncs, matching + /// [_relinkOrphanedRows]. + Future attributeDiveToComputer({ + required String diveId, + required String computerId, + }) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + await _db.customStatement( + 'UPDATE dives SET computer_id = ?, updated_at = ? WHERE id = ?', + [computerId, now, diveId], + ); + await _syncRepository.markRecordPending( + entityType: 'dives', + recordId: diveId, + localUpdatedAt: now, + ); + } catch (e, stackTrace) { + _log.error( + 'Failed to attribute dive $diveId to computer $computerId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + /// Register the dive computer a file import names, reusing an existing row + /// when one already stands for the same physical device (#1288). + /// + /// File imports only ever wrote the `dive_computer_model`/`_serial` + /// display snapshots onto each dive, so a logbook built entirely from + /// files showed a computer on every dive and still reported "No dive + /// computers registered" in the filter, which reads `dive_computers`. + /// + /// The match key is weaker than the download path's, because a file offers + /// less to go on: + /// + /// - With a serial, match on the serial alone (scoped to the diver), the + /// same strong key [findOrCreateComputer] uses. A file's model spelling + /// must not defeat it. + /// - Without one, match a row that also has no serial and whose model, or + /// whose manufacturer + model, normalizes to the same string. Matching + /// the full name too is what lets a file's `'Shearwater Perdix'` find a + /// downloaded row stored as manufacturer `'Shearwater'`, model + /// `'Perdix'`. + /// + /// A serial-bearing row is deliberately never adopted by a serial-less + /// import: two units of one model are common, and collapsing them would + /// misattribute dives with no way to undo it. + /// + /// Returns null when the file names no model, which is the signal to leave + /// the dive unattributed rather than register a placeholder device. + Future findOrRegisterImportedComputer({ + required String model, + String? manufacturer, + String? serialNumber, + String? firmwareVersion, + String? diverId, + }) async { + try { + final normalizedModel = normalizeComputerIdentityPart(model); + if (normalizedModel.isEmpty) return null; + + final query = _db.select(_db.diveComputers) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]); + final normalizedDiverId = diverId?.trim(); + if (normalizedDiverId != null && normalizedDiverId.isNotEmpty) { + query.where((t) => t.diverId.equals(normalizedDiverId)); + } + + // Matched in Dart, like findByHardwareIdentity: a stored serial or + // model may itself carry whitespace from an older import, so trimming + // only the input would miss that row. The rule itself lives in + // [matchImportedComputer] because the beforeOpen self-heal has to apply + // exactly the same one. + final rows = await query.get(); + final match = matchImportedComputer( + model: model, + serialNumber: serialNumber, + diverId: diverId, + candidates: rows.map( + (row) => ImportedComputerCandidate( + id: row.id, + diverId: row.diverId, + manufacturer: row.manufacturer, + model: row.model, + serialNumber: row.serialNumber, + ), + ), + ); + if (match != null) { + return _mapRowToComputer(rows.firstWhere((r) => r.id == match.id)); + } + + // Deterministic, so the import and the beforeOpen self-heal agree and a + // synced fleet converges on one row per device. + final id = importedDiveComputerId( + model: model, + serialNumber: serialNumber, + diverId: diverId, + ); + + // The identity match above reads the row's CURRENT text while the id is + // derived from the FILE's text, so renaming a registered computer makes + // them disagree: the match misses and the id still collides. Adopt the + // row holding it rather than letting the insert throw and abort the + // import. + final byDerivedId = await (_db.select( + _db.diveComputers, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + if (byDerivedId != null) return _mapRowToComputer(byDerivedId); + + final trimmedModel = model.trim(); + final trimmedManufacturer = manufacturer?.trim(); + final now = DateTime.now(); + return await createComputer( + domain.DiveComputer( + id: id, + diverId: diverId, + name: trimmedManufacturer != null && trimmedManufacturer.isNotEmpty + ? '$trimmedManufacturer $trimmedModel' + : trimmedModel, + manufacturer: trimmedManufacturer?.isNotEmpty ?? false + ? trimmedManufacturer + : null, + model: trimmedModel, + serialNumber: serialNumber?.trim().isNotEmpty ?? false + ? serialNumber!.trim() + : null, + firmwareVersion: firmwareVersion?.trim().isNotEmpty ?? false + ? firmwareVersion!.trim() + : null, + createdAt: now, + updatedAt: now, + ), + ); + } catch (e, stackTrace) { + _log.error( + 'Failed to register imported dive computer', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Find or create a dive computer by serial number and model Future findOrCreateComputer({ required String serialNumber, diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index 73f895e508..49e70c14bf 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -15,6 +15,7 @@ import 'package:submersion/features/courses/presentation/providers/course_provid import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_computer_providers.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; import 'package:submersion/features/data_quality/data/services/quality_scan_service.dart'; @@ -513,6 +514,7 @@ class UniversalAdapter implements ImportSourceAdapter { tankPressureRepository: _ref.read(tankPressureRepositoryProvider), courseRepository: _ref.read(courseRepositoryProvider), serviceRecordRepository: _ref.read(serviceRecordRepositoryProvider), + diveComputerRepository: _ref.read(diveComputerRepositoryProvider), ); final settings = _ref.read(settingsProvider); diff --git a/test/core/database/imported_dive_computer_backfill_test.dart b/test/core/database/imported_dive_computer_backfill_test.dart new file mode 100644 index 0000000000..fd0e021a11 --- /dev/null +++ b/test/core/database/imported_dive_computer_backfill_test.dart @@ -0,0 +1,396 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/imported_computer_identity.dart'; + +import '../../helpers/test_database.dart'; + +/// Issue #1288: dives created by a file import carry only the +/// `dive_computer_model`/`_serial` display snapshots. Nothing ever registered +/// a `dive_computers` row for them, so a logbook built entirely from files +/// named a computer on every dive and still reported "No dive computers +/// registered" in the filter, which reads the registry. +/// +/// The #1064 self-heal cannot reach these: it adopts `computer_id` from +/// `dive_data_sources`, and a file import leaves that null too. This one +/// registers the device from the snapshots instead. +void main() { + late AppDatabase db; + + const nowMs = 1750000000000; + + setUp(() async { + db = await setUpTestDatabase(); + }); + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future insertDiver(String id) async { + await db + .into(db.divers) + .insert( + DiversCompanion( + id: Value(id), + name: Value('Diver $id'), + createdAt: const Value(nowMs), + updatedAt: const Value(nowMs), + ), + ); + } + + Future insertDive( + String id, { + String? computerId, + String? model, + String? serial, + String? diverId, + }) async { + await db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diverId: Value(diverId), + diveDateTime: const Value(nowMs), + computerId: Value(computerId), + diveComputerModel: Value(model), + diveComputerSerial: Value(serial), + createdAt: const Value(nowMs), + updatedAt: const Value(nowMs), + ), + ); + } + + Future insertComputer( + String id, { + String? diverId, + String? manufacturer, + String? model, + String? serial, + }) async { + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion( + id: Value(id), + diverId: Value(diverId), + name: Value('Computer $id'), + manufacturer: Value(manufacturer), + model: Value(model), + serialNumber: Value(serial), + createdAt: const Value(nowMs), + updatedAt: const Value(nowMs), + ), + ); + } + + Future insertDataSource( + String id, { + required String diveId, + String? computerId, + String? sourceFormat, + String? sourceFileFormat, + }) async { + final now = DateTime.fromMillisecondsSinceEpoch(nowMs); + await db + .into(db.diveDataSources) + .insert( + DiveDataSourcesCompanion.insert( + id: id, + diveId: diveId, + computerId: Value(computerId), + isPrimary: const Value(true), + sourceFormat: Value(sourceFormat), + sourceFileFormat: Value(sourceFileFormat), + importedAt: now, + createdAt: now, + ), + ); + } + + Future logDeletion(String recordId) async { + await db + .into(db.deletionLog) + .insert( + DeletionLogCompanion.insert( + id: 'del-$recordId', + entityType: 'diveComputers', + recordId: recordId, + deletedAt: nowMs, + ), + ); + } + + Future computerIdOf(String diveId) async { + final row = await (db.select( + db.dives, + )..where((d) => d.id.equals(diveId))).getSingle(); + return row.computerId; + } + + test( + 'registers a computer for a file-imported dive and attributes it', + () async { + await insertDiver('diver-1'); + await insertDive( + 'dive-1', + model: 'Perdix 2', + serial: 'SN-1', + diverId: 'diver-1', + ); + + await db.backfillImportedDiveComputersForTest(); + + final computer = (await db.select(db.diveComputers).get()).single; + expect(computer.model, 'Perdix 2'); + expect(computer.serialNumber, 'SN-1'); + expect(computer.diverId, 'diver-1'); + expect(computer.name, 'Perdix 2'); + expect(await computerIdOf('dive-1'), computer.id); + }, + ); + + test( + 'registers one computer for many dives naming the same device', + () async { + await insertDive('dive-1', model: 'Perdix 2', serial: 'SN-1'); + await insertDive('dive-2', model: 'Perdix 2', serial: 'SN-1'); + await insertDive('dive-3', model: 'Perdix 2', serial: 'SN-1'); + + await db.backfillImportedDiveComputersForTest(); + + final computers = await db.select(db.diveComputers).get(); + expect(computers, hasLength(1)); + for (final id in ['dive-1', 'dive-2', 'dive-3']) { + expect(await computerIdOf(id), computers.single.id); + } + }, + ); + + test('registers a computer per distinct device', () async { + await insertDive('dive-1', model: 'Perdix 2', serial: 'SN-1'); + await insertDive('dive-2', model: 'Teric', serial: 'SN-2'); + + await db.backfillImportedDiveComputersForTest(); + + expect(await db.select(db.diveComputers).get(), hasLength(2)); + }); + + test('leaves a dive that names no computer alone', () async { + await insertDive('dive-manual'); + + await db.backfillImportedDiveComputersForTest(); + + expect(await db.select(db.diveComputers).get(), isEmpty); + expect(await computerIdOf('dive-manual'), isNull); + }); + + test('ignores a blank model string', () async { + await insertDive('dive-blank', model: ' '); + + await db.backfillImportedDiveComputersForTest(); + + expect(await db.select(db.diveComputers).get(), isEmpty); + expect(await computerIdOf('dive-blank'), isNull); + }); + + test('leaves an already attributed dive alone', () async { + await insertComputer('dc-a', model: 'Something Else'); + await insertDive( + 'dive-attributed', + computerId: 'dc-a', + model: 'Perdix 2', + serial: 'SN-1', + ); + + await db.backfillImportedDiveComputersForTest(); + + expect(await computerIdOf('dive-attributed'), 'dc-a'); + expect(await db.select(db.diveComputers).get(), hasLength(1)); + }); + + test('adopts a computer already registered by a download', () async { + // The device was downloaded over BLE and later the same dives arrived in + // a file. Minting a second row would split one physical computer in two, + // and there is no merge action to undo that. + await insertDiver('diver-1'); + await insertComputer( + 'downloaded', + diverId: 'diver-1', + manufacturer: 'Shearwater', + model: 'Perdix 2', + serial: 'SN-1', + ); + await insertDive( + 'dive-1', + model: 'Shearwater Perdix 2', + serial: 'SN-1', + diverId: 'diver-1', + ); + + await db.backfillImportedDiveComputersForTest(); + + expect(await db.select(db.diveComputers).get(), hasLength(1)); + expect(await computerIdOf('dive-1'), 'downloaded'); + }); + + test( + 'registers at the deterministic id so a synced fleet converges', + () async { + await insertDiver('diver-1'); + await insertDive( + 'dive-1', + model: 'Perdix 2', + serial: 'SN-1', + diverId: 'diver-1', + ); + + await db.backfillImportedDiveComputersForTest(); + + expect( + (await db.select(db.diveComputers).get()).single.id, + importedDiveComputerId( + diverId: 'diver-1', + model: 'Perdix 2', + serialNumber: 'SN-1', + ), + ); + }, + ); + + test('does not queue sync work: every device heals independently', () async { + await insertDive('dive-1', model: 'Perdix 2', serial: 'SN-1'); + + await db.backfillImportedDiveComputersForTest(); + + // Deterministic ids mean each device derives the same row locally, so + // pushing them would be pure duplicate traffic (and a rename made on one + // device must not be clobbered by another device's backfill). + expect(await db.select(db.syncRecords).get(), isEmpty); + }); + + test('is idempotent across repeated opens', () async { + await insertDive('dive-1', model: 'Perdix 2', serial: 'SN-1'); + + await db.backfillImportedDiveComputersForTest(); + final firstId = await computerIdOf('dive-1'); + await db.backfillImportedDiveComputersForTest(); + + expect(await db.select(db.diveComputers).get(), hasLength(1)); + expect(await computerIdOf('dive-1'), firstId); + }); + + test('collapses spelling noise onto one registration', () async { + await insertDive('dive-1', model: 'Perdix 2', serial: 'SN-1'); + await insertDive('dive-2', model: ' PERDIX 2 ', serial: ' sn-1 '); + + await db.backfillImportedDiveComputersForTest(); + + final computers = await db.select(db.diveComputers).get(); + expect(computers, hasLength(1)); + expect(await computerIdOf('dive-2'), computers.single.id); + }); + + // beforeOpen runs against minimal old-schema fixtures too. The PRAGMA guard + // must skip rather than raise "no such column". + test('skips a legacy schema that lacks the snapshot columns', () async { + final legacy = AppDatabase( + NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute( + 'PRAGMA user_version = ${AppDatabase.currentSchemaVersion}', + ); + rawDb.execute('CREATE TABLE dives (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute("INSERT INTO dives (id) VALUES ('legacy-dive')"); + }, + ), + ); + addTearDown(legacy.close); + + final rows = await legacy.customSelect('SELECT id FROM dives').get(); + + expect(rows.single.read('id'), 'legacy-dive'); + }); + + // deleteComputer nulls dives.computer_id and writes a tombstone, but leaves + // the dive_computer_model/_serial snapshots in place by design. Those are + // exactly the rows this backfill claims, so without a guard a deliberate + // delete would not survive the next app open. + group('respects a deliberate delete', () { + test('does not resurrect a tombstoned computer', () async { + await insertDiver('diver-1'); + await insertDive( + 'dive-1', + model: 'Perdix 2', + serial: 'SN-1', + diverId: 'diver-1', + ); + // The row the user deleted was registered at the deterministic id, so + // re-minting it would resurrect a tombstoned primary key: the peer that + // applied the delete would just delete it again, forever. + await logDeletion( + importedDiveComputerId( + diverId: 'diver-1', + model: 'Perdix 2', + serialNumber: 'SN-1', + ), + ); + + await db.backfillImportedDiveComputersForTest(); + + expect(await db.select(db.diveComputers).get(), isEmpty); + expect(await computerIdOf('dive-1'), isNull); + }); + + test('does not invent a computer for a downloaded dive', () async { + // The download path stamps the same model/serial snapshots onto every + // dive it writes. After its computer is deleted, those snapshots must + // not conjure a phantom device, which would also be a corrupted copy: + // the snapshot is the combined full name, with no manufacturer. + await insertDive( + 'dive-downloaded', + model: 'Shearwater Perdix', + serial: 'SN-123', + ); + await insertDataSource( + 'src-1', + diveId: 'dive-downloaded', + sourceFormat: 'dive_computer', + ); + + await db.backfillImportedDiveComputersForTest(); + + expect(await db.select(db.diveComputers).get(), isEmpty); + expect(await computerIdOf('dive-downloaded'), isNull); + }); + + test('still registers a file-imported dive alongside a download', () async { + await insertDive( + 'dive-downloaded', + model: 'Shearwater Perdix', + serial: 'SN-123', + ); + await insertDataSource( + 'src-1', + diveId: 'dive-downloaded', + sourceFormat: 'dive_computer', + ); + await insertDive('dive-imported', model: 'Suunto D5'); + await insertDataSource( + 'src-2', + diveId: 'dive-imported', + sourceFileFormat: 'uddf', + ); + + await db.backfillImportedDiveComputersForTest(); + + final computers = await db.select(db.diveComputers).get(); + expect(computers, hasLength(1)); + expect(computers.single.model, 'Suunto D5'); + expect(await computerIdOf('dive-imported'), computers.single.id); + expect(await computerIdOf('dive-downloaded'), isNull); + }); + }); +} diff --git a/test/core/services/export/uddf/uddf_computer_manufacturer_test.dart b/test/core/services/export/uddf/uddf_computer_manufacturer_test.dart new file mode 100644 index 0000000000..ce2ebef3e1 --- /dev/null +++ b/test/core/services/export/uddf/uddf_computer_manufacturer_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/export/uddf/uddf_full_import_service.dart'; +import 'package:submersion/core/services/export/uddf/uddf_import_service.dart'; + +/// UDDF names the vendor in ``, but the dive-attribution +/// map only ever carried model/serial/firmware, so an imported computer was +/// registered with a null manufacturer and could not match a downloaded row +/// that stores vendor and product separately (#1288). +const _uddf = ''' + + + + + Test + Diver + + + + Shearwater Perdix 2 + + Shearwater + + Perdix 2 + 2013766D + + + + + + + + + 2024-03-01T10:00:00 + + + + + 10.0 + 0 + + + 20.0 + 600 + + + + 20.0 + 1800 + + + + + +'''; + +/// UDDF's `` legitimately carries `
`/`` +/// siblings of ``. Reading the element's whole subtree would splice +/// those into the vendor string. +const _uddfManufacturerWithoutName = ''' + + + + + Test + Diver + + + + Perdix 2 + +
+ Vancouver + Canada +
+ + support@example.com + +
+ Perdix 2 +
+
+
+
+ + + + + 2024-03-01T10:00:00 + + + + + 10.0 + 0 + + + + 20.0 + 1800 + + + + +
+'''; + +void main() { + test('UddfImportService carries the dive computer manufacturer', () async { + final result = await UddfImportService().importDivesFromUddf(_uddf); + final dive = result['dives']!.single; + + expect(dive['diveComputerModel'], 'Perdix 2'); + expect(dive['diveComputerManufacturer'], 'Shearwater'); + }); + + test( + 'UddfFullImportService carries the dive computer manufacturer', + () async { + final result = await UddfFullImportService().importAllDataFromUddf(_uddf); + final dive = result.dives.single; + + expect(dive['diveComputerModel'], 'Perdix 2'); + expect(dive['diveComputerManufacturer'], 'Shearwater'); + }, + ); + + test('ignores a manufacturer element that carries no name', () async { + final result = await UddfImportService().importDivesFromUddf( + _uddfManufacturerWithoutName, + ); + final dive = result['dives']!.single; + + // Never 'VancouverCanada': the address and contact subtrees are not the + // vendor name. + expect(dive['diveComputerManufacturer'], isNull); + }); +} diff --git a/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart b/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart new file mode 100644 index 0000000000..4191556935 --- /dev/null +++ b/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart @@ -0,0 +1,310 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/imported_computer_identity.dart'; +import 'package:submersion/core/services/export/models/uddf_import_result.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/certifications/data/repositories/certification_repository.dart'; +import 'package:submersion/features/courses/data/repositories/course_repository.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart' + as domain; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; +import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; +import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1288: a logbook built from a file import named a dive computer on +/// every dive's Details card, yet Dives > Filter still read "No dive +/// computers registered" and offered nothing to filter by. The importer wrote +/// only the `dive_computer_model`/`_serial` display snapshots and never +/// registered a `dive_computers` row, which is what the filter reads. +/// +/// These run against a real in-memory database rather than mocks: the bug was +/// the absence of a write, so only the persisted rows prove the fix. +void main() { + late AppDatabase db; + late UddfEntityImporter importer; + late ImportRepositories repos; + const diverId = 'diver-1'; + + setUp(() async { + db = await setUpTestDatabase(); + importer = UddfEntityImporter(); + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.divers) + .insert( + DiversCompanion( + id: const Value(diverId), + name: const Value('Test Diver'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + repos = ImportRepositories( + tripRepository: TripRepository(), + equipmentRepository: EquipmentRepository(), + equipmentSetRepository: EquipmentSetRepository(), + buddyRepository: BuddyRepository(), + diveCenterRepository: DiveCenterRepository(), + certificationRepository: CertificationRepository(), + tagRepository: TagRepository(), + diveTypeRepository: DiveTypeRepository(), + siteRepository: SiteRepository(), + diveRepository: DiveRepository(), + tankPressureRepository: TankPressureRepository(), + courseRepository: CourseRepository(), + diveComputerRepository: DiveComputerRepository(), + ); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Map diveEntry({ + required int day, + String? model, + String? serial, + String? firmware, + String? manufacturer, + }) => { + 'dateTime': DateTime(2024, 3, day, 10), + 'maxDepth': 30.0, + 'diveComputerModel': model, + 'diveComputerSerial': serial, + 'diveComputerFirmware': firmware, + 'diveComputerManufacturer': manufacturer, + }; + + Future runImport(List> dives) => + importer.import( + data: UddfImportResult(dives: dives), + selections: UddfImportSelections( + dives: {for (var i = 0; i < dives.length; i++) i}, + ), + repositories: repos, + diverId: diverId, + ); + + test('registers one computer for dives naming the same device', () async { + final result = await runImport([ + diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1'), + diveEntry(day: 2, model: 'Perdix 2', serial: 'SN-1'), + ]); + + expect(result.dives, 2); + + final computers = await db.select(db.diveComputers).get(); + expect(computers, hasLength(1)); + expect(computers.single.model, 'Perdix 2'); + expect(computers.single.serialNumber, 'SN-1'); + expect(computers.single.diverId, diverId); + }); + + test('stamps computer_id on every imported dive', () async { + await runImport([ + diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1'), + diveEntry(day: 2, model: 'Perdix 2', serial: 'SN-1'), + ]); + + final computer = (await db.select(db.diveComputers).get()).single; + final dives = await db.select(db.dives).get(); + expect(dives, hasLength(2)); + expect(dives.every((d) => d.computerId == computer.id), isTrue); + // The display snapshots stay: they are what the Details card renders + // when no computer is registered, and what exports carry. + expect(dives.every((d) => d.diveComputerModel == 'Perdix 2'), isTrue); + }); + + test('stamps computer_id on the provenance row', () async { + await runImport([diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1')]); + + final computer = (await db.select(db.diveComputers).get()).single; + final source = (await db.select(db.diveDataSources).get()).single; + expect(source.computerId, computer.id); + }); + + test('registers a computer per distinct device in one file', () async { + await runImport([ + diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1'), + diveEntry(day: 2, model: 'Teric', serial: 'SN-2'), + ]); + + final computers = await db.select(db.diveComputers).get(); + expect(computers, hasLength(2)); + expect(computers.map((c) => c.model).toSet(), {'Perdix 2', 'Teric'}); + }); + + test( + 'registers a computer when the file gives a model but no serial', + () async { + await runImport([diveEntry(day: 1, model: 'Suunto D5')]); + + final computer = (await db.select(db.diveComputers).get()).single; + expect(computer.serialNumber, isNull); + expect(computer.model, 'Suunto D5'); + expect((await db.select(db.dives).get()).single.computerId, computer.id); + }, + ); + + test('records the manufacturer when the file supplies one', () async { + await runImport([ + diveEntry(day: 1, model: 'Perdix 2', manufacturer: 'Shearwater'), + ]); + + final computer = (await db.select(db.diveComputers).get()).single; + expect(computer.manufacturer, 'Shearwater'); + expect(computer.name, 'Shearwater Perdix 2'); + }); + + test('records the firmware the file reports', () async { + await runImport([ + diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1', firmware: '92'), + ]); + + expect( + (await db.select(db.diveComputers).get()).single.firmwareVersion, + '92', + ); + }); + + test('leaves dives unattributed when the file names no computer', () async { + await runImport([diveEntry(day: 1)]); + + expect(await db.select(db.diveComputers).get(), isEmpty); + expect((await db.select(db.dives).get()).single.computerId, isNull); + }); + + test( + 'reuses an already registered computer instead of duplicating it', + () async { + await runImport([diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1')]); + await runImport([diveEntry(day: 5, model: 'Perdix 2', serial: 'SN-1')]); + + final computers = await db.select(db.diveComputers).get(); + expect(computers, hasLength(1)); + final dives = await db.select(db.dives).get(); + expect(dives, hasLength(2)); + expect(dives.every((d) => d.computerId == computers.single.id), isTrue); + }, + ); + + test( + 'registers at the deterministic id so a synced fleet converges', + () async { + await runImport([diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1')]); + + expect( + (await db.select(db.diveComputers).get()).single.id, + importedDiveComputerId( + diverId: diverId, + model: 'Perdix 2', + serialNumber: 'SN-1', + ), + ); + }, + ); + + test( + 'the query behind the filter dropdown finds the imported computer', + () async { + await runImport([diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1')]); + + // allDiveComputersProvider, which the Dives > Filter dropdown builds its + // list from, is exactly this call. Coming back empty is what rendered + // "No dive computers registered" while every dive showed a computer. + final computers = await DiveComputerRepository().getAllComputers( + diverId: diverId, + ); + + expect(computers, hasLength(1)); + expect(computers.single.model, 'Perdix 2'); + }, + ); + + test('a registration failure does not abort the import', () async { + // Attribution is cosmetic next to the dives themselves: a registry + // problem must degrade to "unattributed", never cost the user the + // logbook they were importing. + final result = await importer.import( + data: UddfImportResult( + dives: [diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1')], + ), + selections: const UddfImportSelections(dives: {0}), + repositories: ImportRepositories( + tripRepository: TripRepository(), + equipmentRepository: EquipmentRepository(), + equipmentSetRepository: EquipmentSetRepository(), + buddyRepository: BuddyRepository(), + diveCenterRepository: DiveCenterRepository(), + certificationRepository: CertificationRepository(), + tagRepository: TagRepository(), + diveTypeRepository: DiveTypeRepository(), + siteRepository: SiteRepository(), + diveRepository: DiveRepository(), + tankPressureRepository: TankPressureRepository(), + courseRepository: CourseRepository(), + diveComputerRepository: _FailingComputerRepository(), + ), + diverId: diverId, + ); + + expect(result.dives, 1); + final dives = await db.select(db.dives).get(); + expect(dives, hasLength(1)); + expect(dives.single.computerId, isNull); + // The display snapshot still lands, so the Details card is unaffected. + expect(dives.single.diveComputerModel, 'Perdix 2'); + }); + + test('adopts a computer already registered by a download', () async { + // The download path stores vendor and product separately; the file + // carries them as one string. The dive must join the existing device, + // not fork a second record for it. + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion( + id: const Value('downloaded'), + diverId: const Value(diverId), + name: const Value('Shearwater Perdix 2'), + manufacturer: const Value('Shearwater'), + model: const Value('Perdix 2'), + serialNumber: const Value('SN-1'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + + await runImport([ + diveEntry(day: 1, model: 'Shearwater Perdix 2', serial: 'SN-1'), + ]); + + expect(await db.select(db.diveComputers).get(), hasLength(1)); + expect((await db.select(db.dives).get()).single.computerId, 'downloaded'); + }); +} + +/// Stands in for a registry that cannot be written, to pin that the importer +/// treats attribution as best-effort. +class _FailingComputerRepository extends DiveComputerRepository { + @override + Future findOrRegisterImportedComputer({ + required String model, + String? manufacturer, + String? serialNumber, + String? firmwareVersion, + String? diverId, + }) async => throw StateError('registry unavailable'); +} diff --git a/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart b/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart index 2a6da42f5e..77e0833a1a 100644 --- a/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart +++ b/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/database/imported_computer_identity.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart' as domain; @@ -1145,4 +1146,244 @@ void main() { expect(source.exitLongitude, 98.76489); }); }); + + // --------------------------------------------------------------------------- + // findOrRegisterImportedComputer (issue #1288) + // + // File imports name a computer on every dive but register no + // `dive_computers` row, so the Dives filter reports "No dive computers + // registered". Registration keys on the serial when the file supplies one + // and falls back to the model string when it does not. + // --------------------------------------------------------------------------- + group('findOrRegisterImportedComputer', () { + // dive_computers.diver_id is a real FK, so the owners must exist. + setUp(() async { + await insertDiver('diver-1'); + await insertDiver('diver-2'); + }); + + test('creates a computer when nothing matches', () async { + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix 2', + manufacturer: 'Shearwater', + serialNumber: 'SN-999', + diverId: 'diver-1', + ); + + expect(computer, isNotNull); + expect(computer!.model, 'Perdix 2'); + expect(computer.manufacturer, 'Shearwater'); + expect(computer.serialNumber, 'SN-999'); + expect(computer.diverId, 'diver-1'); + expect(computer.name, 'Shearwater Perdix 2'); + + final rows = await db.select(db.diveComputers).get(); + expect(rows, hasLength(1)); + expect(rows.single.id, computer.id); + }); + + test('reuses an existing computer with the same serial', () async { + await insertComputer( + id: 'existing', + diverId: 'diver-1', + serialNumber: 'SN-999', + manufacturer: 'Shearwater', + model: 'Perdix', + ); + + // A different model spelling must not defeat the serial match: the + // serial is the strong key, exactly as on the download path. + final computer = await repository.findOrRegisterImportedComputer( + model: 'Shearwater Perdix AI', + serialNumber: 'SN-999', + diverId: 'diver-1', + ); + + expect(computer!.id, 'existing'); + expect(await db.select(db.diveComputers).get(), hasLength(1)); + }); + + test('matches a serial despite stored whitespace', () async { + await insertComputer( + id: 'existing', + diverId: 'diver-1', + serialNumber: ' SN-999 ', + ); + + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix', + serialNumber: 'SN-999', + diverId: 'diver-1', + ); + + expect(computer!.id, 'existing'); + }); + + test('reuses a serial-less computer with the same model', () async { + await insertComputer( + id: 'existing', + diverId: 'diver-1', + manufacturer: null, + model: 'Perdix 2', + serialNumber: null, + ); + + final computer = await repository.findOrRegisterImportedComputer( + model: ' perdix 2 ', + diverId: 'diver-1', + ); + + expect(computer!.id, 'existing'); + expect(await db.select(db.diveComputers).get(), hasLength(1)); + }); + + test('matches a serial-less row on its manufacturer plus model', () async { + // The download path stores vendor and product separately; a file + // usually carries them jammed into one string. + await insertComputer( + id: 'existing', + diverId: 'diver-1', + manufacturer: 'Shearwater', + model: 'Perdix', + serialNumber: null, + ); + + final computer = await repository.findOrRegisterImportedComputer( + model: 'Shearwater Perdix', + diverId: 'diver-1', + ); + + expect(computer!.id, 'existing'); + }); + + test( + 'does not adopt a serial-bearing row when the file has no serial', + () async { + await insertComputer( + id: 'registered', + diverId: 'diver-1', + model: 'Perdix 2', + serialNumber: 'SN-999', + ); + + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix 2', + diverId: 'diver-1', + ); + + expect(computer!.id, isNot('registered')); + expect(await db.select(db.diveComputers).get(), hasLength(2)); + }, + ); + + test('does not reuse a computer belonging to another diver', () async { + await insertComputer( + id: 'other-diver', + diverId: 'diver-2', + serialNumber: 'SN-999', + ); + + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix', + serialNumber: 'SN-999', + diverId: 'diver-1', + ); + + expect(computer!.id, isNot('other-diver')); + expect(computer.diverId, 'diver-1'); + }); + + test('is idempotent: a second call registers nothing new', () async { + final first = await repository.findOrRegisterImportedComputer( + model: 'Perdix 2', + diverId: 'diver-1', + ); + final second = await repository.findOrRegisterImportedComputer( + model: 'Perdix 2', + diverId: 'diver-1', + ); + + expect(second!.id, first!.id); + expect(await db.select(db.diveComputers).get(), hasLength(1)); + }); + + test('derives a deterministic id from the normalized identity', () async { + // Every device must derive the SAME id for the same physical computer, + // or the beforeOpen backfill mints one row per synced device and there + // is no merge UI to clean that up. + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix 2', + diverId: 'diver-1', + ); + + expect( + computer!.id, + importedDiveComputerId( + diverId: 'diver-1', + model: 'Perdix 2', + serialNumber: null, + ), + ); + // Normalization feeds the id, so spelling noise cannot fork it. + expect( + importedDiveComputerId( + diverId: 'diver-1', + model: ' PERDIX 2 ', + serialNumber: null, + ), + computer.id, + ); + }); + + test('adopts the row already holding the deterministic id', () async { + // The user renamed a computer that a previous import registered, so + // the identity match now misses while the derived id still collides. + // Inserting blind would throw UNIQUE constraint failed and abort the + // whole import. + final id = importedDiveComputerId( + diverId: 'diver-1', + model: 'Perdix', + serialNumber: null, + ); + await insertComputer( + id: id, + diverId: 'diver-1', + manufacturer: null, + model: 'My Renamed Perdix', + serialNumber: null, + ); + + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix', + diverId: 'diver-1', + ); + + expect(computer!.id, id); + expect(computer.model, 'My Renamed Perdix'); + expect(await db.select(db.diveComputers).get(), hasLength(1)); + }); + + test('registers nothing when the model is blank', () async { + final computer = await repository.findOrRegisterImportedComputer( + model: ' ', + diverId: 'diver-1', + ); + + expect(computer, isNull); + expect(await db.select(db.diveComputers).get(), isEmpty); + }); + + test('marks the new computer pending so it syncs', () async { + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix 2', + diverId: 'diver-1', + ); + + final pending = await (db.select( + db.syncRecords, + )..where((t) => t.entityType.equals('diveComputers'))).get(); + expect(pending.map((r) => r.recordId), contains(computer!.id)); + expect(pending.single.syncStatus, 'pending'); + }); + }); } From 95983ea7d664028414187c0469b705abd68b78b4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:39:48 -0400 Subject: [PATCH 064/122] fix(media): give the Set-time mm:ss field a keyboard with a colon TextInputType.numberWithOptions() maps to a digits-only keypad on iOS and TYPE_CLASS_NUMBER on Android, neither of which has ':', so 12:30 could only be entered with the slider. TextInputType.datetime carries the colon on both platforms. Widget test pins the keyboard type. --- .../widgets/set_media_time_dialog.dart | 4 +++- .../widgets/set_media_time_dialog_test.dart | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/features/media/presentation/widgets/set_media_time_dialog.dart b/lib/features/media/presentation/widgets/set_media_time_dialog.dart index 7d5a3bdd1f..ba7e4b3ff4 100644 --- a/lib/features/media/presentation/widgets/set_media_time_dialog.dart +++ b/lib/features/media/presentation/widgets/set_media_time_dialog.dart @@ -124,7 +124,9 @@ class _SetMediaTimeDialogState extends State { TextField( controller: _controller, autofocus: true, - keyboardType: const TextInputType.numberWithOptions(), + // datetime, not number: the number keypad on iOS and Android has + // no ':' key, and mm:ss cannot be typed without one. + keyboardType: TextInputType.datetime, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp('[0-9:]')), ], diff --git a/test/features/media/presentation/widgets/set_media_time_dialog_test.dart b/test/features/media/presentation/widgets/set_media_time_dialog_test.dart index 524227d349..5615e2fe24 100644 --- a/test/features/media/presentation/widgets/set_media_time_dialog_test.dart +++ b/test/features/media/presentation/widgets/set_media_time_dialog_test.dart @@ -71,6 +71,20 @@ void main() { expect(tester.widget(find.byType(Slider)).max, 1800); }); + testWidgets('the field asks for a keyboard that can type a colon', ( + tester, + ) async { + await pump(tester); + + // A digits-only keypad (iOS number pad, Android TYPE_CLASS_NUMBER) has + // no ':' key, so mm:ss could only be entered via the slider. The + // datetime type carries ':' on both platforms. + expect( + tester.widget(field()).keyboardType, + TextInputType.datetime, + ); + }); + testWidgets('previews the moment on the mini profile as it changes', ( tester, ) async { From 10595ae91c7660011b978686550813694a77e151 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:40:41 -0400 Subject: [PATCH 065/122] feat(sites): look up location details from typed coordinates (#1187) --- .../presentation/pages/site_edit_page.dart | 137 ++++++++++ .../edit_sections/location_section.dart | 17 +- lib/l10n/arb/app_ar.arb | 9 +- lib/l10n/arb/app_de.arb | 9 +- lib/l10n/arb/app_en.arb | 9 +- lib/l10n/arb/app_es.arb | 9 +- lib/l10n/arb/app_fr.arb | 9 +- lib/l10n/arb/app_he.arb | 9 +- lib/l10n/arb/app_hu.arb | 9 +- lib/l10n/arb/app_it.arb | 9 +- lib/l10n/arb/app_localizations.dart | 44 +++- lib/l10n/arb/app_localizations_ar.dart | 26 +- lib/l10n/arb/app_localizations_de.dart | 27 +- lib/l10n/arb/app_localizations_en.dart | 27 +- lib/l10n/arb/app_localizations_es.dart | 28 +- lib/l10n/arb/app_localizations_fr.dart | 28 +- lib/l10n/arb/app_localizations_he.dart | 27 +- lib/l10n/arb/app_localizations_hu.dart | 27 +- lib/l10n/arb/app_localizations_it.dart | 28 +- lib/l10n/arb/app_localizations_nl.dart | 27 +- lib/l10n/arb/app_localizations_pt.dart | 28 +- lib/l10n/arb/app_localizations_zh.dart | 23 +- lib/l10n/arb/app_nl.arb | 9 +- lib/l10n/arb/app_pt.arb | 9 +- lib/l10n/arb/app_zh.arb | 9 +- ...ite_edit_lookup_from_coordinates_test.dart | 249 ++++++++++++++++++ ...cation_section_coordinate_format_test.dart | 1 + 27 files changed, 818 insertions(+), 25 deletions(-) create mode 100644 test/features/dive_sites/presentation/pages/site_edit_lookup_from_coordinates_test.dart diff --git a/lib/features/dive_sites/presentation/pages/site_edit_page.dart b/lib/features/dive_sites/presentation/pages/site_edit_page.dart index f5ae26c39c..cb950cd6ec 100644 --- a/lib/features/dive_sites/presentation/pages/site_edit_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_edit_page.dart @@ -996,6 +996,9 @@ class _SiteEditPageState extends ConsumerState { isGettingLocation: _isGettingLocation, onUseMyLocation: _useMyLocation, onPickFromMap: _pickFromMap, + onLookupFromCoordinates: _parsedCoordinates() == null + ? null + : _lookupFromCoordinates, units: units, coordinatesExtras: _coordinateExtras(), altitudeExtras: _mergeExtras('altitude'), @@ -1466,6 +1469,140 @@ class _SiteEditPageState extends ConsumerState { } } + /// The typed coordinates, or null while either field does not parse. + GeoPoint? _parsedCoordinates() { + final lat = double.tryParse(_latitudeController.text); + final lng = double.tryParse(_longitudeController.text); + if (lat == null || lng == null) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return GeoPoint(lat, lng); + } + + /// Explicit lookup for the typed coordinates (issue #1187). Fills empty + /// fields; when nothing was empty and the lookup differs, offers to + /// replace. Never runs on save. + Future _lookupFromCoordinates() async { + final point = _parsedCoordinates(); + if (point == null) return; + setState(() => _isGettingLocation = true); + try { + final lookup = await ref + .read(locationServiceProvider) + .reverseGeocode( + point.latitude, + point.longitude, + languageCode: ref.read(placeNameLanguageProvider), + ); + if (!mounted) return; + // The busy indicator must stop before any dialog waits for input. + setState(() => _isGettingLocation = false); + + if (lookup.networkFailed) { + _showLookupSnackBar(context.l10n.diveSites_edit_snackbar_lookupFailed); + return; + } + if (lookup.isEmpty) { + _showLookupSnackBar( + context.l10n.diveSites_edit_snackbar_lookupNothingFound, + ); + return; + } + + var changed = false; + setState(() { + changed = _applyPlaceLookup(lookup, overwrite: false); + if (changed) _hasChanges = true; + }); + if (changed) return; + + final differing = _differingLookupValues(lookup); + if (differing.isEmpty) return; + final replace = await _confirmReplaceLocationDetails(differing); + if (!mounted || !replace) return; + setState(() { + if (_applyPlaceLookup(lookup, overwrite: true)) _hasChanges = true; + }); + } finally { + if (mounted) setState(() => _isGettingLocation = false); + } + } + + void _showLookupSnackBar(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + /// Field label to found value, for the fields whose found value is + /// non-blank and differs from what the form shows. + Map _differingLookupValues(PlaceLookup lookup) { + final l10n = context.l10n; + final out = {}; + void compare(String label, String current, String? found) { + if (found == null || found.trim().isEmpty) return; + if (current.trim() == found.trim()) return; + out[label] = found.trim(); + } + + compare( + l10n.diveSites_edit_field_country_label, + _countryController.text, + lookup.country, + ); + compare( + l10n.diveSites_edit_field_region_label, + _regionController.text, + lookup.region, + ); + compare( + l10n.diveSites_edit_field_city_label, + _cityController.text, + lookup.locality, + ); + compare( + l10n.diveSites_edit_field_bodyOfWater_label, + _bodyOfWaterController.text, + lookup.bodyOfWater, + ); + return out; + } + + Future _confirmReplaceLocationDetails( + Map differing, + ) async { + final l10n = context.l10n; + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.diveSites_edit_lookupReplace_title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.diveSites_edit_lookupReplace_body), + const SizedBox(height: 12), + for (final entry in differing.entries) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('${entry.key}: ${entry.value}'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.diveSites_edit_lookupReplace_keep), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.diveSites_edit_lookupReplace_replace), + ), + ], + ), + ); + return result ?? false; + } + Future _showSpeciesPicker() async { final selectedIds = _expectedSpecies.map((s) => s.id).toSet(); diff --git a/lib/features/dive_sites/presentation/widgets/edit_sections/location_section.dart b/lib/features/dive_sites/presentation/widgets/edit_sections/location_section.dart index 128876b1ea..155b56871f 100644 --- a/lib/features/dive_sites/presentation/widgets/edit_sections/location_section.dart +++ b/lib/features/dive_sites/presentation/widgets/edit_sections/location_section.dart @@ -30,6 +30,7 @@ class LocationSection extends StatelessWidget { required this.isGettingLocation, required this.onUseMyLocation, required this.onPickFromMap, + required this.onLookupFromCoordinates, required this.units, this.coordinatesExtras, this.altitudeExtras, @@ -52,6 +53,10 @@ class LocationSection extends StatelessWidget { final bool isGettingLocation; final VoidCallback onUseMyLocation; final VoidCallback onPickFromMap; + + /// Reverse-geocodes the typed coordinates (issue #1187). Null while the + /// coordinates do not parse, which disables the button. + final VoidCallback? onLookupFromCoordinates; final UnitFormatter units; final MergeFieldExtras? coordinatesExtras; final MergeFieldExtras? altitudeExtras; @@ -95,7 +100,9 @@ class LocationSection extends StatelessWidget { ), Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 6), - child: Row( + child: Wrap( + spacing: 12, + runSpacing: 4, children: [ TextButton.icon( onPressed: isGettingLocation ? null : onUseMyLocation, @@ -112,12 +119,18 @@ class LocationSection extends StatelessWidget { : l10n.diveSites_edit_gps_useMyLocation, ), ), - const SizedBox(width: 12), TextButton.icon( onPressed: onPickFromMap, icon: const Icon(Icons.map, size: 16), label: Text(l10n.diveSites_edit_gps_pickFromMap), ), + TextButton.icon( + onPressed: isGettingLocation + ? null + : onLookupFromCoordinates, + icon: const Icon(Icons.travel_explore, size: 16), + label: Text(l10n.diveSites_edit_gps_lookupFromCoordinates), + ), ], ), ), diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index c965b5dee1..685568bf82 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2725,7 +2725,7 @@ "diveSites_similarSite_useHint": "مشابه لموقع غوص موجود \"{siteName}\". انقر للاستخدام.", "diveSites_similarSite_warning": "يوجد بالفعل موقع مشابه: \"{siteName}\"", "diveSites_edit_gps_gettingLocation": "جارٍ الحصول على الموقع...", - "diveSites_edit_gps_helperText": "اختر طريقة تحديد الموقع - سيتم ملء الدولة والمنطقة تلقائياً", + "diveSites_edit_gps_helperText": "اختر طريقة لتحديد الموقع أو ابحث عن الإحداثيات لملء البلد والمنطقة والبلدة والمسطح المائي تلقائيًا", "diveSites_edit_gps_latitude_hint": "مثال: 21.4225", "diveSites_edit_gps_latitude_label": "خط العرض", "diveSites_edit_gps_latitude_validation": "خط عرض غير صالح", @@ -2733,6 +2733,13 @@ "diveSites_edit_gps_longitude_label": "خط الطول", "diveSites_edit_gps_longitude_validation": "خط طول غير صالح", "diveSites_edit_gps_pickFromMap": "اختيار من الخريطة", + "diveSites_edit_gps_lookupFromCoordinates": "البحث من الإحداثيات", + "diveSites_edit_snackbar_lookupNothingFound": "لم يتم العثور على تفاصيل موقع لهذه الإحداثيات", + "diveSites_edit_snackbar_lookupFailed": "فشل البحث عن الموقع. تحقق من الاتصال وحاول مرة أخرى.", + "diveSites_edit_lookupReplace_title": "استبدال تفاصيل الموقع؟", + "diveSites_edit_lookupReplace_body": "عثر البحث على قيم مختلفة لهذه الحقول:", + "diveSites_edit_lookupReplace_replace": "استبدال", + "diveSites_edit_lookupReplace_keep": "إبقاء", "diveSites_edit_gps_useMyLocation": "استخدام موقعي", "diveSites_edit_hazards_helperText": "أدرج أي مخاطر أو اعتبارات سلامة", "diveSites_edit_hazards_hint": "مثال: تيارات قوية، حركة قوارب، قناديل بحر، شعاب مرجانية حادة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 879cd37563..371dbe8ac7 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -2725,7 +2725,7 @@ "diveSites_similarSite_useHint": "Ähnelt vorhandenem Tauchplatz „{siteName}“. Zum Verwenden tippen.", "diveSites_similarSite_warning": "Ein ähnlicher Tauchplatz existiert bereits: „{siteName}“", "diveSites_edit_gps_gettingLocation": "Wird ermittelt...", - "diveSites_edit_gps_helperText": "Wählen Sie eine Standortmethode - Koordinaten füllen Land und Region automatisch aus", + "diveSites_edit_gps_helperText": "Wählen Sie eine Standortmethode oder suchen Sie die Koordinaten, um Land, Region, Ort und Gewässer automatisch auszufüllen", "diveSites_edit_gps_latitude_hint": "z. B. 21,4225", "diveSites_edit_gps_latitude_label": "Breitengrad", "diveSites_edit_gps_latitude_validation": "Ungültiger Breitengrad", @@ -2733,6 +2733,13 @@ "diveSites_edit_gps_longitude_label": "Längengrad", "diveSites_edit_gps_longitude_validation": "Ungültiger Längengrad", "diveSites_edit_gps_pickFromMap": "Auf Karte auswählen", + "diveSites_edit_gps_lookupFromCoordinates": "Aus Koordinaten ermitteln", + "diveSites_edit_snackbar_lookupNothingFound": "Keine Ortsangaben für diese Koordinaten gefunden", + "diveSites_edit_snackbar_lookupFailed": "Ortssuche fehlgeschlagen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + "diveSites_edit_lookupReplace_title": "Ortsangaben ersetzen?", + "diveSites_edit_lookupReplace_body": "Die Suche hat für diese Felder andere Werte gefunden:", + "diveSites_edit_lookupReplace_replace": "Ersetzen", + "diveSites_edit_lookupReplace_keep": "Behalten", "diveSites_edit_gps_useMyLocation": "Meinen Standort verwenden", "diveSites_edit_hazards_helperText": "Listen Sie alle Gefahren oder Sicherheitshinweise auf", "diveSites_edit_hazards_hint": "z. B. Starke Strömungen, Bootsverkehr, Quallen, scharfe Korallen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9e623a70a7..5cc24eb3cd 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4801,7 +4801,7 @@ } }, "diveSites_edit_gps_gettingLocation": "Getting...", - "diveSites_edit_gps_helperText": "Choose a location method - coordinates will auto-fill country and region", + "diveSites_edit_gps_helperText": "Choose a location method or look up the coordinates to auto-fill country, region, town and body of water", "diveSites_edit_gps_latitude_hint": "e.g., 21.4225", "diveSites_edit_gps_latitude_label": "Latitude", "diveSites_edit_gps_latitude_validation": "Invalid latitude", @@ -4809,6 +4809,13 @@ "diveSites_edit_gps_longitude_label": "Longitude", "diveSites_edit_gps_longitude_validation": "Invalid longitude", "diveSites_edit_gps_pickFromMap": "Pick from Map", + "diveSites_edit_gps_lookupFromCoordinates": "Look up from coordinates", + "diveSites_edit_snackbar_lookupNothingFound": "No location details found for these coordinates", + "diveSites_edit_snackbar_lookupFailed": "Location lookup failed. Check your connection and try again.", + "diveSites_edit_lookupReplace_title": "Replace location details?", + "diveSites_edit_lookupReplace_body": "The lookup found different values for these fields:", + "diveSites_edit_lookupReplace_replace": "Replace", + "diveSites_edit_lookupReplace_keep": "Keep", "diveSites_edit_gps_useMyLocation": "Use My Location", "diveSites_edit_hazards_helperText": "List any hazards or safety considerations", "diveSites_edit_hazards_hint": "e.g., Strong currents, boat traffic, jellyfish, sharp coral", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 440cb578e9..13b5ea03ce 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2725,7 +2725,7 @@ "diveSites_similarSite_useHint": "Similar a un sitio de buceo existente \"{siteName}\". Toca para usar.", "diveSites_similarSite_warning": "Ya existe un sitio similar: \"{siteName}\"", "diveSites_edit_gps_gettingLocation": "Obteniendo...", - "diveSites_edit_gps_helperText": "Elige un metodo de ubicacion - las coordenadas completaran automaticamente el pais y la region", + "diveSites_edit_gps_helperText": "Elige un método de ubicación o consulta las coordenadas para rellenar país, región, localidad y masa de agua", "diveSites_edit_gps_latitude_hint": "p. ej., 21.4225", "diveSites_edit_gps_latitude_label": "Latitud", "diveSites_edit_gps_latitude_validation": "Latitud no valida", @@ -2733,6 +2733,13 @@ "diveSites_edit_gps_longitude_label": "Longitud", "diveSites_edit_gps_longitude_validation": "Longitud no valida", "diveSites_edit_gps_pickFromMap": "Elegir del mapa", + "diveSites_edit_gps_lookupFromCoordinates": "Consultar por coordenadas", + "diveSites_edit_snackbar_lookupNothingFound": "No se encontraron datos de ubicación para estas coordenadas", + "diveSites_edit_snackbar_lookupFailed": "La consulta de ubicación falló. Comprueba tu conexión e inténtalo de nuevo.", + "diveSites_edit_lookupReplace_title": "¿Reemplazar los datos de ubicación?", + "diveSites_edit_lookupReplace_body": "La consulta encontró valores distintos para estos campos:", + "diveSites_edit_lookupReplace_replace": "Reemplazar", + "diveSites_edit_lookupReplace_keep": "Mantener", "diveSites_edit_gps_useMyLocation": "Usar mi ubicacion", "diveSites_edit_hazards_helperText": "Lista de peligros o consideraciones de seguridad", "diveSites_edit_hazards_hint": "p. ej., Corrientes fuertes, trafico de embarcaciones, medusas, coral afilado", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index e985bff5d8..a4651200d0 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -2652,7 +2652,7 @@ "diveSites_similarSite_useHint": "Similaire à un site de plongée existant « {siteName} ». Appuyez pour l'utiliser.", "diveSites_similarSite_warning": "Un site similaire existe déjà : « {siteName} »", "diveSites_edit_gps_gettingLocation": "Obtention...", - "diveSites_edit_gps_helperText": "Choisissez une methode de localisation - les coordonnees rempliront automatiquement le pays et la region", + "diveSites_edit_gps_helperText": "Choisissez une méthode de localisation ou recherchez les coordonnées pour remplir le pays, la région, la ville et le plan d'eau", "diveSites_edit_gps_latitude_hint": "ex. 21.4225", "diveSites_edit_gps_latitude_label": "Latitude", "diveSites_edit_gps_latitude_validation": "Latitude invalide", @@ -2660,6 +2660,13 @@ "diveSites_edit_gps_longitude_label": "Longitude", "diveSites_edit_gps_longitude_validation": "Longitude invalide", "diveSites_edit_gps_pickFromMap": "Choisir sur la carte", + "diveSites_edit_gps_lookupFromCoordinates": "Rechercher depuis les coordonnées", + "diveSites_edit_snackbar_lookupNothingFound": "Aucune information de lieu trouvée pour ces coordonnées", + "diveSites_edit_snackbar_lookupFailed": "La recherche de lieu a échoué. Vérifiez votre connexion et réessayez.", + "diveSites_edit_lookupReplace_title": "Remplacer les informations de lieu ?", + "diveSites_edit_lookupReplace_body": "La recherche a trouvé des valeurs différentes pour ces champs :", + "diveSites_edit_lookupReplace_replace": "Remplacer", + "diveSites_edit_lookupReplace_keep": "Conserver", "diveSites_edit_gps_useMyLocation": "Utiliser ma position", "diveSites_edit_hazards_helperText": "Listez les dangers ou les considerations de securite", "diveSites_edit_hazards_hint": "ex. Courants forts, trafic maritime, meduses, corail tranchant", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 12aa4dc079..ed39509f49 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -2652,7 +2652,7 @@ "diveSites_similarSite_useHint": "דומה לאתר צלילה קיים \"{siteName}\". הקש כדי להשתמש.", "diveSites_similarSite_warning": "כבר קיים אתר דומה: \"{siteName}\"", "diveSites_edit_gps_gettingLocation": "מאתר...", - "diveSites_edit_gps_helperText": "בחר שיטת מיקום - הקואורדינטות ימלאו אוטומטית את המדינה והאזור", + "diveSites_edit_gps_helperText": "בחרו שיטת מיקום או חפשו את הקואורדינטות כדי למלא אוטומטית מדינה, אזור, עיר וגוף מים", "diveSites_edit_gps_latitude_hint": "לדוגמה, 21.4225", "diveSites_edit_gps_latitude_label": "קו רוחב", "diveSites_edit_gps_latitude_validation": "קו רוחב לא חוקי", @@ -2660,6 +2660,13 @@ "diveSites_edit_gps_longitude_label": "קו אורך", "diveSites_edit_gps_longitude_validation": "קו אורך לא חוקי", "diveSites_edit_gps_pickFromMap": "בחר מהמפה", + "diveSites_edit_gps_lookupFromCoordinates": "חיפוש לפי קואורדינטות", + "diveSites_edit_snackbar_lookupNothingFound": "לא נמצאו פרטי מיקום לקואורדינטות אלה", + "diveSites_edit_snackbar_lookupFailed": "חיפוש המיקום נכשל. בדקו את החיבור ונסו שוב.", + "diveSites_edit_lookupReplace_title": "להחליף את פרטי המיקום?", + "diveSites_edit_lookupReplace_body": "החיפוש מצא ערכים שונים לשדות אלה:", + "diveSites_edit_lookupReplace_replace": "החלפה", + "diveSites_edit_lookupReplace_keep": "שמירה", "diveSites_edit_gps_useMyLocation": "השתמש במיקום שלי", "diveSites_edit_hazards_helperText": "רשום סכנות או שיקולי בטיחות", "diveSites_edit_hazards_hint": "לדוגמה, זרמים חזקים, תנועת סירות, מדוזות, אלמוגים חדים", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index c76b198dd6..a2671bd57d 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -2652,7 +2652,7 @@ "diveSites_similarSite_useHint": "Hasonló egy meglévő merülőhelyhez: „{siteName}“. Koppintson a használathoz.", "diveSites_similarSite_warning": "Már létezik hasonló merülőhely: „{siteName}“", "diveSites_edit_gps_gettingLocation": "Lekeres...", - "diveSites_edit_gps_helperText": "Valasszon helymeghatarozoasi modszert - a koordinatak automatikusan kitoltik az orszagot es a regiot", + "diveSites_edit_gps_helperText": "Válasszon helymeghatározási módot, vagy kérdezze le a koordinátákat az ország, régió, település és víztest automatikus kitöltéséhez", "diveSites_edit_gps_latitude_hint": "pl. 21.4225", "diveSites_edit_gps_latitude_label": "Szelesseg", "diveSites_edit_gps_latitude_validation": "Ervenytelen szelesseg", @@ -2660,6 +2660,13 @@ "diveSites_edit_gps_longitude_label": "Hosszusag", "diveSites_edit_gps_longitude_validation": "Ervenytelen hosszusag", "diveSites_edit_gps_pickFromMap": "Kivalasztas terkeprol", + "diveSites_edit_gps_lookupFromCoordinates": "Lekérdezés a koordinátákból", + "diveSites_edit_snackbar_lookupNothingFound": "Nem található helyadat ezekhez a koordinátákhoz", + "diveSites_edit_snackbar_lookupFailed": "A helylekérdezés nem sikerült. Ellenőrizze a kapcsolatot, és próbálja újra.", + "diveSites_edit_lookupReplace_title": "Lecseréli a helyadatokat?", + "diveSites_edit_lookupReplace_body": "A lekérdezés eltérő értékeket talált ezekhez a mezőkhöz:", + "diveSites_edit_lookupReplace_replace": "Csere", + "diveSites_edit_lookupReplace_keep": "Megtartás", "diveSites_edit_gps_useMyLocation": "Sajat helyzet hasznalata", "diveSites_edit_hazards_helperText": "Soroljon fel veszelyeket vagy biztonsagi megfontolasokat", "diveSites_edit_hazards_hint": "pl. Eros aramlatok, hajoforgalom, meduzak, eles korallok", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 331c46b468..a32423deda 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -2652,7 +2652,7 @@ "diveSites_similarSite_useHint": "Simile a un sito di immersione esistente \"{siteName}\". Tocca per usare.", "diveSites_similarSite_warning": "Esiste già un sito simile: \"{siteName}\"", "diveSites_edit_gps_gettingLocation": "Acquisizione...", - "diveSites_edit_gps_helperText": "Scegli un metodo di localizzazione - le coordinate compileranno automaticamente paese e regione", + "diveSites_edit_gps_helperText": "Scegli un metodo di localizzazione o cerca le coordinate per compilare paese, regione, città e specchio d'acqua", "diveSites_edit_gps_latitude_hint": "es. 21.4225", "diveSites_edit_gps_latitude_label": "Latitudine", "diveSites_edit_gps_latitude_validation": "Latitudine non valida", @@ -2660,6 +2660,13 @@ "diveSites_edit_gps_longitude_label": "Longitudine", "diveSites_edit_gps_longitude_validation": "Longitudine non valida", "diveSites_edit_gps_pickFromMap": "Scegli dalla mappa", + "diveSites_edit_gps_lookupFromCoordinates": "Cerca dalle coordinate", + "diveSites_edit_snackbar_lookupNothingFound": "Nessun dettaglio di località trovato per queste coordinate", + "diveSites_edit_snackbar_lookupFailed": "Ricerca della località non riuscita. Controlla la connessione e riprova.", + "diveSites_edit_lookupReplace_title": "Sostituire i dettagli di località?", + "diveSites_edit_lookupReplace_body": "La ricerca ha trovato valori diversi per questi campi:", + "diveSites_edit_lookupReplace_replace": "Sostituisci", + "diveSites_edit_lookupReplace_keep": "Mantieni", "diveSites_edit_gps_useMyLocation": "Usa la mia posizione", "diveSites_edit_hazards_helperText": "Elenca eventuali pericoli o considerazioni sulla sicurezza", "diveSites_edit_hazards_hint": "es. Correnti forti, traffico nautico, meduse, coralli taglienti", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 254dc5ccd2..6163a7f84c 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -13942,7 +13942,7 @@ abstract class AppLocalizations { /// No description provided for @diveSites_edit_gps_helperText. /// /// In en, this message translates to: - /// **'Choose a location method - coordinates will auto-fill country and region'** + /// **'Choose a location method or look up the coordinates to auto-fill country, region, town and body of water'** String get diveSites_edit_gps_helperText; /// No description provided for @diveSites_edit_gps_latitude_hint. @@ -13987,6 +13987,48 @@ abstract class AppLocalizations { /// **'Pick from Map'** String get diveSites_edit_gps_pickFromMap; + /// No description provided for @diveSites_edit_gps_lookupFromCoordinates. + /// + /// In en, this message translates to: + /// **'Look up from coordinates'** + String get diveSites_edit_gps_lookupFromCoordinates; + + /// No description provided for @diveSites_edit_snackbar_lookupNothingFound. + /// + /// In en, this message translates to: + /// **'No location details found for these coordinates'** + String get diveSites_edit_snackbar_lookupNothingFound; + + /// No description provided for @diveSites_edit_snackbar_lookupFailed. + /// + /// In en, this message translates to: + /// **'Location lookup failed. Check your connection and try again.'** + String get diveSites_edit_snackbar_lookupFailed; + + /// No description provided for @diveSites_edit_lookupReplace_title. + /// + /// In en, this message translates to: + /// **'Replace location details?'** + String get diveSites_edit_lookupReplace_title; + + /// No description provided for @diveSites_edit_lookupReplace_body. + /// + /// In en, this message translates to: + /// **'The lookup found different values for these fields:'** + String get diveSites_edit_lookupReplace_body; + + /// No description provided for @diveSites_edit_lookupReplace_replace. + /// + /// In en, this message translates to: + /// **'Replace'** + String get diveSites_edit_lookupReplace_replace; + + /// No description provided for @diveSites_edit_lookupReplace_keep. + /// + /// In en, this message translates to: + /// **'Keep'** + String get diveSites_edit_lookupReplace_keep; + /// No description provided for @diveSites_edit_gps_useMyLocation. /// /// 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 fbc94c8ec6..028002d35c 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8098,7 +8098,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'اختر طريقة تحديد الموقع - سيتم ملء الدولة والمنطقة تلقائياً'; + 'اختر طريقة لتحديد الموقع أو ابحث عن الإحداثيات لملء البلد والمنطقة والبلدة والمسطح المائي تلقائيًا'; @override String get diveSites_edit_gps_latitude_hint => 'مثال: 21.4225'; @@ -8121,6 +8121,30 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'اختيار من الخريطة'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => 'البحث من الإحداثيات'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'لم يتم العثور على تفاصيل موقع لهذه الإحداثيات'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'فشل البحث عن الموقع. تحقق من الاتصال وحاول مرة أخرى.'; + + @override + String get diveSites_edit_lookupReplace_title => 'استبدال تفاصيل الموقع؟'; + + @override + String get diveSites_edit_lookupReplace_body => + 'عثر البحث على قيم مختلفة لهذه الحقول:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'استبدال'; + + @override + String get diveSites_edit_lookupReplace_keep => 'إبقاء'; + @override String get diveSites_edit_gps_useMyLocation => 'استخدام موقعي'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 65b72636c7..f4eef57bce 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8248,7 +8248,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Wählen Sie eine Standortmethode - Koordinaten füllen Land und Region automatisch aus'; + 'Wählen Sie eine Standortmethode oder suchen Sie die Koordinaten, um Land, Region, Ort und Gewässer automatisch auszufüllen'; @override String get diveSites_edit_gps_latitude_hint => 'z. B. 21,4225'; @@ -8271,6 +8271,31 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Auf Karte auswählen'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Aus Koordinaten ermitteln'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'Keine Ortsangaben für diese Koordinaten gefunden'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'Ortssuche fehlgeschlagen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.'; + + @override + String get diveSites_edit_lookupReplace_title => 'Ortsangaben ersetzen?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'Die Suche hat für diese Felder andere Werte gefunden:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Ersetzen'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Behalten'; + @override String get diveSites_edit_gps_useMyLocation => 'Meinen Standort verwenden'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 6f990229b0..c28eaa0b89 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8115,7 +8115,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Choose a location method - coordinates will auto-fill country and region'; + 'Choose a location method or look up the coordinates to auto-fill country, region, town and body of water'; @override String get diveSites_edit_gps_latitude_hint => 'e.g., 21.4225'; @@ -8138,6 +8138,31 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Pick from Map'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Look up from coordinates'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'No location details found for these coordinates'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'Location lookup failed. Check your connection and try again.'; + + @override + String get diveSites_edit_lookupReplace_title => 'Replace location details?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'The lookup found different values for these fields:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Replace'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Keep'; + @override String get diveSites_edit_gps_useMyLocation => 'Use My Location'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 784f0bb619..e5aad8d308 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8258,7 +8258,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Elige un metodo de ubicacion - las coordenadas completaran automaticamente el pais y la region'; + 'Elige un método de ubicación o consulta las coordenadas para rellenar país, región, localidad y masa de agua'; @override String get diveSites_edit_gps_latitude_hint => 'p. ej., 21.4225'; @@ -8281,6 +8281,32 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Elegir del mapa'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Consultar por coordenadas'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'No se encontraron datos de ubicación para estas coordenadas'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'La consulta de ubicación falló. Comprueba tu conexión e inténtalo de nuevo.'; + + @override + String get diveSites_edit_lookupReplace_title => + '¿Reemplazar los datos de ubicación?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'La consulta encontró valores distintos para estos campos:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Reemplazar'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Mantener'; + @override String get diveSites_edit_gps_useMyLocation => 'Usar mi ubicacion'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 0f36721450..2b4b08db62 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8290,7 +8290,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Choisissez une methode de localisation - les coordonnees rempliront automatiquement le pays et la region'; + 'Choisissez une méthode de localisation ou recherchez les coordonnées pour remplir le pays, la région, la ville et le plan d\'eau'; @override String get diveSites_edit_gps_latitude_hint => 'ex. 21.4225'; @@ -8313,6 +8313,32 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Choisir sur la carte'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Rechercher depuis les coordonnées'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'Aucune information de lieu trouvée pour ces coordonnées'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'La recherche de lieu a échoué. Vérifiez votre connexion et réessayez.'; + + @override + String get diveSites_edit_lookupReplace_title => + 'Remplacer les informations de lieu ?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'La recherche a trouvé des valeurs différentes pour ces champs :'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Remplacer'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Conserver'; + @override String get diveSites_edit_gps_useMyLocation => 'Utiliser ma position'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 39b488f4ea..2331787dcb 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8053,7 +8053,7 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'בחר שיטת מיקום - הקואורדינטות ימלאו אוטומטית את המדינה והאזור'; + 'בחרו שיטת מיקום או חפשו את הקואורדינטות כדי למלא אוטומטית מדינה, אזור, עיר וגוף מים'; @override String get diveSites_edit_gps_latitude_hint => 'לדוגמה, 21.4225'; @@ -8076,6 +8076,31 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'בחר מהמפה'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'חיפוש לפי קואורדינטות'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'לא נמצאו פרטי מיקום לקואורדינטות אלה'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'חיפוש המיקום נכשל. בדקו את החיבור ונסו שוב.'; + + @override + String get diveSites_edit_lookupReplace_title => 'להחליף את פרטי המיקום?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'החיפוש מצא ערכים שונים לשדות אלה:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'החלפה'; + + @override + String get diveSites_edit_lookupReplace_keep => 'שמירה'; + @override String get diveSites_edit_gps_useMyLocation => 'השתמש במיקום שלי'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 3bf45a67d2..8c5aa1807f 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8239,7 +8239,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Valasszon helymeghatarozoasi modszert - a koordinatak automatikusan kitoltik az orszagot es a regiot'; + 'Válasszon helymeghatározási módot, vagy kérdezze le a koordinátákat az ország, régió, település és víztest automatikus kitöltéséhez'; @override String get diveSites_edit_gps_latitude_hint => 'pl. 21.4225'; @@ -8262,6 +8262,31 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Kivalasztas terkeprol'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Lekérdezés a koordinátákból'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'Nem található helyadat ezekhez a koordinátákhoz'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'A helylekérdezés nem sikerült. Ellenőrizze a kapcsolatot, és próbálja újra.'; + + @override + String get diveSites_edit_lookupReplace_title => 'Lecseréli a helyadatokat?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'A lekérdezés eltérő értékeket talált ezekhez a mezőkhöz:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Csere'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Megtartás'; + @override String get diveSites_edit_gps_useMyLocation => 'Sajat helyzet hasznalata'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index ea7196aa98..05d7a99406 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8258,7 +8258,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Scegli un metodo di localizzazione - le coordinate compileranno automaticamente paese e regione'; + 'Scegli un metodo di localizzazione o cerca le coordinate per compilare paese, regione, città e specchio d\'acqua'; @override String get diveSites_edit_gps_latitude_hint => 'es. 21.4225'; @@ -8282,6 +8282,32 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Scegli dalla mappa'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Cerca dalle coordinate'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'Nessun dettaglio di località trovato per queste coordinate'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'Ricerca della località non riuscita. Controlla la connessione e riprova.'; + + @override + String get diveSites_edit_lookupReplace_title => + 'Sostituire i dettagli di località?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'La ricerca ha trovato valori diversi per questi campi:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Sostituisci'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Mantieni'; + @override String get diveSites_edit_gps_useMyLocation => 'Usa la mia posizione'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 867c0b7562..57b010eeba 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8191,7 +8191,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Kies een locatiemethode - coordinaten vullen automatisch land en regio in'; + 'Kies een locatiemethode of zoek de coördinaten op om land, regio, plaats en water automatisch in te vullen'; @override String get diveSites_edit_gps_latitude_hint => 'bijv. 21.4225'; @@ -8214,6 +8214,31 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Kies op de kaart'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Opzoeken op coördinaten'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'Geen locatiegegevens gevonden voor deze coördinaten'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'Locatie opzoeken mislukt. Controleer je verbinding en probeer het opnieuw.'; + + @override + String get diveSites_edit_lookupReplace_title => 'Locatiegegevens vervangen?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'Het opzoeken vond andere waarden voor deze velden:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Vervangen'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Behouden'; + @override String get diveSites_edit_gps_useMyLocation => 'Gebruik mijn locatie'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index d236d6ad75..d02053d116 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8257,7 +8257,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'Escolha um metodo de localizacao - as coordenadas preencherao automaticamente pais e regiao'; + 'Escolha um método de localização ou consulte as coordenadas para preencher país, região, cidade e corpo de água'; @override String get diveSites_edit_gps_latitude_hint => 'ex., 21.4225'; @@ -8280,6 +8280,32 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => 'Escolher no Mapa'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => + 'Consultar pelas coordenadas'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => + 'Nenhum detalhe de localização encontrado para estas coordenadas'; + + @override + String get diveSites_edit_snackbar_lookupFailed => + 'A consulta de localização falhou. Verifique a sua ligação e tente novamente.'; + + @override + String get diveSites_edit_lookupReplace_title => + 'Substituir os detalhes de localização?'; + + @override + String get diveSites_edit_lookupReplace_body => + 'A consulta encontrou valores diferentes para estes campos:'; + + @override + String get diveSites_edit_lookupReplace_replace => 'Substituir'; + + @override + String get diveSites_edit_lookupReplace_keep => 'Manter'; + @override String get diveSites_edit_gps_useMyLocation => 'Usar Minha Localizacao'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 509e3a7c40..a1d3161964 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -7867,7 +7867,7 @@ class AppLocalizationsZh extends AppLocalizations { String get diveSites_edit_gps_gettingLocation => '获取中...'; @override - String get diveSites_edit_gps_helperText => '选择定位方式 - 坐标将自动填充国家和地区'; + String get diveSites_edit_gps_helperText => '选择定位方式或根据坐标查找,以自动填写国家、地区、城镇和水域'; @override String get diveSites_edit_gps_latitude_hint => 'e.g., 21.4225'; @@ -7890,6 +7890,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveSites_edit_gps_pickFromMap => '选择从地图'; + @override + String get diveSites_edit_gps_lookupFromCoordinates => '根据坐标查找'; + + @override + String get diveSites_edit_snackbar_lookupNothingFound => '未找到这些坐标的地点信息'; + + @override + String get diveSites_edit_snackbar_lookupFailed => '地点查找失败。请检查网络连接后重试。'; + + @override + String get diveSites_edit_lookupReplace_title => '替换地点信息?'; + + @override + String get diveSites_edit_lookupReplace_body => '查找结果中以下字段的值不同:'; + + @override + String get diveSites_edit_lookupReplace_replace => '替换'; + + @override + String get diveSites_edit_lookupReplace_keep => '保留'; + @override String get diveSites_edit_gps_useMyLocation => '使用我的位置'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index d684cad21c..2cd6c9154e 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -2725,7 +2725,7 @@ "diveSites_similarSite_useHint": "Vergelijkbaar met bestaande duiklocatie \"{siteName}\". Tik om te gebruiken.", "diveSites_similarSite_warning": "Er bestaat al een vergelijkbare locatie: \"{siteName}\"", "diveSites_edit_gps_gettingLocation": "Ophalen...", - "diveSites_edit_gps_helperText": "Kies een locatiemethode - coordinaten vullen automatisch land en regio in", + "diveSites_edit_gps_helperText": "Kies een locatiemethode of zoek de coördinaten op om land, regio, plaats en water automatisch in te vullen", "diveSites_edit_gps_latitude_hint": "bijv. 21.4225", "diveSites_edit_gps_latitude_label": "Breedtegraad", "diveSites_edit_gps_latitude_validation": "Ongeldige breedtegraad", @@ -2733,6 +2733,13 @@ "diveSites_edit_gps_longitude_label": "Lengtegraad", "diveSites_edit_gps_longitude_validation": "Ongeldige lengtegraad", "diveSites_edit_gps_pickFromMap": "Kies op de kaart", + "diveSites_edit_gps_lookupFromCoordinates": "Opzoeken op coördinaten", + "diveSites_edit_snackbar_lookupNothingFound": "Geen locatiegegevens gevonden voor deze coördinaten", + "diveSites_edit_snackbar_lookupFailed": "Locatie opzoeken mislukt. Controleer je verbinding en probeer het opnieuw.", + "diveSites_edit_lookupReplace_title": "Locatiegegevens vervangen?", + "diveSites_edit_lookupReplace_body": "Het opzoeken vond andere waarden voor deze velden:", + "diveSites_edit_lookupReplace_replace": "Vervangen", + "diveSites_edit_lookupReplace_keep": "Behouden", "diveSites_edit_gps_useMyLocation": "Gebruik mijn locatie", "diveSites_edit_hazards_helperText": "Vermeld eventuele gevaren of veiligheidsoverwegingen", "diveSites_edit_hazards_hint": "bijv. sterke stroming, bootverkeer, kwallen, scherp koraal", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 29edf2aad4..feebdcd4d8 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2725,7 +2725,7 @@ "diveSites_similarSite_useHint": "Semelhante a um local de mergulho existente \"{siteName}\". Toque para usar.", "diveSites_similarSite_warning": "Já existe um local semelhante: \"{siteName}\"", "diveSites_edit_gps_gettingLocation": "Obtendo...", - "diveSites_edit_gps_helperText": "Escolha um metodo de localizacao - as coordenadas preencherao automaticamente pais e regiao", + "diveSites_edit_gps_helperText": "Escolha um método de localização ou consulte as coordenadas para preencher país, região, cidade e corpo de água", "diveSites_edit_gps_latitude_hint": "ex., 21.4225", "diveSites_edit_gps_latitude_label": "Latitude", "diveSites_edit_gps_latitude_validation": "Latitude invalida", @@ -2733,6 +2733,13 @@ "diveSites_edit_gps_longitude_label": "Longitude", "diveSites_edit_gps_longitude_validation": "Longitude invalida", "diveSites_edit_gps_pickFromMap": "Escolher no Mapa", + "diveSites_edit_gps_lookupFromCoordinates": "Consultar pelas coordenadas", + "diveSites_edit_snackbar_lookupNothingFound": "Nenhum detalhe de localização encontrado para estas coordenadas", + "diveSites_edit_snackbar_lookupFailed": "A consulta de localização falhou. Verifique a sua ligação e tente novamente.", + "diveSites_edit_lookupReplace_title": "Substituir os detalhes de localização?", + "diveSites_edit_lookupReplace_body": "A consulta encontrou valores diferentes para estes campos:", + "diveSites_edit_lookupReplace_replace": "Substituir", + "diveSites_edit_lookupReplace_keep": "Manter", "diveSites_edit_gps_useMyLocation": "Usar Minha Localizacao", "diveSites_edit_hazards_helperText": "Liste quaisquer perigos ou consideracoes de seguranca", "diveSites_edit_hazards_hint": "ex., Correntes fortes, trafego de embarcacoes, aguas-vivas, corais afiados", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index ff42be42ff..b659c3a887 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2858,7 +2858,7 @@ "diveSites_similarSite_useHint": "与现有潜点\"{siteName}\"相似。点按以使用。", "diveSites_similarSite_warning": "已存在相似的潜点:\"{siteName}\"", "diveSites_edit_gps_gettingLocation": "获取中...", - "diveSites_edit_gps_helperText": "选择定位方式 - 坐标将自动填充国家和地区", + "diveSites_edit_gps_helperText": "选择定位方式或根据坐标查找,以自动填写国家、地区、城镇和水域", "diveSites_edit_gps_latitude_hint": "e.g., 21.4225", "diveSites_edit_gps_latitude_label": "纬度", "diveSites_edit_gps_latitude_validation": "无效的纬度", @@ -2866,6 +2866,13 @@ "diveSites_edit_gps_longitude_label": "经度", "diveSites_edit_gps_longitude_validation": "无效的经度", "diveSites_edit_gps_pickFromMap": "选择从地图", + "diveSites_edit_gps_lookupFromCoordinates": "根据坐标查找", + "diveSites_edit_snackbar_lookupNothingFound": "未找到这些坐标的地点信息", + "diveSites_edit_snackbar_lookupFailed": "地点查找失败。请检查网络连接后重试。", + "diveSites_edit_lookupReplace_title": "替换地点信息?", + "diveSites_edit_lookupReplace_body": "查找结果中以下字段的值不同:", + "diveSites_edit_lookupReplace_replace": "替换", + "diveSites_edit_lookupReplace_keep": "保留", "diveSites_edit_gps_useMyLocation": "使用我的位置", "diveSites_edit_hazards_helperText": "列出任何危险或安全注意事项", "diveSites_edit_hazards_hint": "例如:强水流、船只交通、水母、尖锐珊瑚", diff --git a/test/features/dive_sites/presentation/pages/site_edit_lookup_from_coordinates_test.dart b/test/features/dive_sites/presentation/pages/site_edit_lookup_from_coordinates_test.dart new file mode 100644 index 0000000000..3f26bcf326 --- /dev/null +++ b/test/features/dive_sites/presentation/pages/site_edit_lookup_from_coordinates_test.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/pages/site_edit_page.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +class _FakeLocationService implements LocationService { + _FakeLocationService(this.place); + + final PlaceLookup place; + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async => place; + + @override + Future getCurrentLocation({ + bool includeGeocoding = true, + Duration timeout = const Duration(seconds: 15), + String languageCode = LocationService.defaultLanguageCode, + }) async => LocationResult( + latitude: 47.027631, + longitude: 8.400640, + accuracy: 5, + country: place.country, + region: place.region, + locality: place.locality, + bodyOfWater: place.bodyOfWater, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const _weggis = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', +); + +const _lookupButton = 'Look up from coordinates'; + +void main() { + late SharedPreferences prefs; + late SiteRepository repo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + repo = SiteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpEditor( + WidgetTester tester, { + PlaceLookup place = _weggis, + DiveSite? seeded, + }) async { + tester.view.physicalSize = const Size(900, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + allDiversProvider.overrideWith((_) async => const []), + shareByDefaultProvider.overrideWith((_) async => false), + validatedCurrentDiverIdProvider.overrideWith((_) async => null), + if (seeded != null) + siteProvider(seeded.id).overrideWith((_) async => seeded), + locationServiceProvider.overrideWithValue( + _FakeLocationService(place), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SiteEditPage( + siteId: seeded?.id, + embedded: true, + onSaved: (_) {}, + onCancel: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// The Location group rests collapsed for an existing site; tapping its + /// header expands it. + Future expandLocation(WidgetTester tester) async { + await tester.tap(find.text('Location')); + await tester.pumpAndSettle(); + } + + const coords = GeoPoint(47.027631, 8.400640); + + Future seedSite({ + String? country, + String? region, + String? city, + String? bodyOfWater, + }) => repo.createSite( + DiveSite( + id: '', + name: 'Hertenstein', + location: coords, + country: country, + region: region, + city: city, + bodyOfWater: bodyOfWater, + ), + ); + + testWidgets('the button is disabled until coordinates are present', ( + tester, + ) async { + await pumpEditor(tester); + await tester.tap(find.text('Add GPS position or altitude')); + await tester.pumpAndSettle(); + + final button = find.widgetWithText(TextButton, _lookupButton); + expect(tester.widget(button).onPressed, isNull); + + await tester.tap(find.text('Use My Location')); + await tester.pumpAndSettle(); + expect(tester.widget(button).onPressed, isNotNull); + }); + + testWidgets('fills the empty fields and saves them', (tester) async { + final seeded = await seedSite(); + await pumpEditor(tester, seeded: seeded); + await expandLocation(tester); + + await tester.tap(find.text(_lookupButton)); + await tester.pumpAndSettle(); + + expect(find.text('Weggis'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + final saved = await repo.getSiteById(seeded.id); + expect(saved!.city, 'Weggis'); + expect(saved.bodyOfWater, 'Lake Lucerne'); + expect(saved.country, 'Switzerland'); + }); + + testWidgets('offers to replace when nothing was empty and values differ', ( + tester, + ) async { + final seeded = await seedSite( + country: 'Schweiz', + region: 'Luzern', + city: 'Weggis', + bodyOfWater: 'Vierwaldstättersee', + ); + await pumpEditor(tester, seeded: seeded); + await expandLocation(tester); + + await tester.tap(find.text(_lookupButton)); + await tester.pumpAndSettle(); + + expect(find.text('Replace location details?'), findsOneWidget); + // Only the differing fields are listed; the town is identical. + expect(find.textContaining('Lake Lucerne'), findsOneWidget); + expect(find.textContaining('Body of Water: '), findsOneWidget); + expect(find.textContaining('City: '), findsNothing); + + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + expect(find.text('Lake Lucerne'), findsOneWidget); + expect(find.text('Vierwaldstättersee'), findsNothing); + }); + + testWidgets('Keep leaves the fields alone', (tester) async { + final seeded = await seedSite( + country: 'Schweiz', + region: 'Luzern', + city: 'Weggis', + bodyOfWater: 'Vierwaldstättersee', + ); + await pumpEditor(tester, seeded: seeded); + await expandLocation(tester); + + await tester.tap(find.text(_lookupButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Keep')); + await tester.pumpAndSettle(); + + expect(find.text('Vierwaldstättersee'), findsOneWidget); + expect(find.text('Lake Lucerne'), findsNothing); + }); + + testWidgets('says so when nothing was found', (tester) async { + final seeded = await seedSite(); + await pumpEditor(tester, seeded: seeded, place: const PlaceLookup.empty()); + await expandLocation(tester); + + await tester.tap(find.text(_lookupButton)); + await tester.pumpAndSettle(); + + expect( + find.text('No location details found for these coordinates'), + findsOneWidget, + ); + }); + + testWidgets('reports an unreachable geocoder', (tester) async { + final seeded = await seedSite(); + await pumpEditor( + tester, + seeded: seeded, + place: const PlaceLookup.unavailable(), + ); + await expandLocation(tester); + + await tester.tap(find.text(_lookupButton)); + await tester.pumpAndSettle(); + + expect( + find.text('Location lookup failed. Check your connection and try again.'), + findsOneWidget, + ); + }); +} diff --git a/test/features/dive_sites/presentation/widgets/location_section_coordinate_format_test.dart b/test/features/dive_sites/presentation/widgets/location_section_coordinate_format_test.dart index f5c65d827f..8dd91007fb 100644 --- a/test/features/dive_sites/presentation/widgets/location_section_coordinate_format_test.dart +++ b/test/features/dive_sites/presentation/widgets/location_section_coordinate_format_test.dart @@ -46,6 +46,7 @@ void main() { isGettingLocation: false, onUseMyLocation: () {}, onPickFromMap: () {}, + onLookupFromCoordinates: null, units: const UnitFormatter(AppSettings()), ); From 4a617e5379958b30aeb6cfea09e5fb2438c2499a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:41:12 -0400 Subject: [PATCH 066/122] fix(media): tell the user Live Photos are unsupported instead of raising 3302 Writing dive data to a Live Photo failed with the raw "PHPhotosErrorDomain error 3302". A Live Photo is a still paired with a short video, and Photos' content editing session expects the output to represent both resources; the handlers fed it a bare rewritten still, so Photos rejected the resource. Neither handler checked the asset subtype, so every Live Photo took the plain-image path. The iOS and macOS handlers now check `mediaSubtypes.contains(.photoLive)` at the point where they choose a write strategy, and return a new LIVE_PHOTO_UNSUPPORTED code before opening a content editing session. `MetadataWriteException` carries the native code so the viewer can show a localized message; the service keeps an English fallback for callers without a context, and deliberately discards PhotoKit's own text, which is an untranslated error-domain string. macOS was the reported platform, but iOS shares the same code path and the same missing check, so both are fixed. Metadata still does not reach a Live Photo. Apple's own PHLivePhotoEditingContext route re-encodes the pairing and, per Apple Developer Forums thread 769154 and immich-app/immich#26124, does not make the metadata stick, so this reports the limitation rather than pretending to work. Users who want the data written can duplicate the shot as a still photo and write to the copy, which the message says. Refs #795 Deliberately not a closing keyword: this reports the limitation but does not write metadata to a Live Photo, so #795 stays open to track the real write. --- ios/Runner/MetadataWriteHandler.swift | 11 ++ .../data/services/metadata_write_service.dart | 34 ++++- .../presentation/pages/media_viewer_page.dart | 8 +- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 1 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 4 + lib/l10n/arb/app_localizations_de.dart | 4 + lib/l10n/arb/app_localizations_en.dart | 4 + lib/l10n/arb/app_localizations_es.dart | 4 + lib/l10n/arb/app_localizations_fr.dart | 4 + lib/l10n/arb/app_localizations_he.dart | 4 + lib/l10n/arb/app_localizations_hu.dart | 4 + lib/l10n/arb/app_localizations_it.dart | 4 + lib/l10n/arb/app_localizations_nl.dart | 4 + lib/l10n/arb/app_localizations_pt.dart | 4 + lib/l10n/arb/app_localizations_zh.dart | 4 + lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + macos/Runner/MetadataWriteHandler.swift | 12 ++ .../services/metadata_write_service_test.dart | 144 ++++++++++++++++++ .../media_viewer_write_metadata_test.dart | 23 +++ 29 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 test/features/media/data/services/metadata_write_service_test.dart diff --git a/ios/Runner/MetadataWriteHandler.swift b/ios/Runner/MetadataWriteHandler.swift index d739e1d33f..3c225bab29 100644 --- a/ios/Runner/MetadataWriteHandler.swift +++ b/ios/Runner/MetadataWriteHandler.swift @@ -92,6 +92,17 @@ class MetadataWriteHandler: NSObject { if isVideo { writeVideoMetadata(asset: asset, metadata: metadata, description: description, keepOriginal: keepOriginal, result: result) + } else if asset.mediaSubtypes.contains(.photoLive) { + // A Live Photo is a still paired with a short video. Photos' content + // editing session expects the output to represent both resources, so + // the plain-image round-trip in writePhotoMetadata is rejected with + // PHPhotosErrorDomain error 3302. Refuse up front with a code the + // Dart layer can translate rather than surfacing that raw error. + result(FlutterError( + code: "LIVE_PHOTO_UNSUPPORTED", + message: "Live Photos cannot be edited in place without breaking the paired video.", + details: nil + )) } else { writePhotoMetadata(asset: asset, metadata: metadata, description: description, result: result) } diff --git a/lib/features/media/data/services/metadata_write_service.dart b/lib/features/media/data/services/metadata_write_service.dart index 88ea4a3001..8e61b4607b 100644 --- a/lib/features/media/data/services/metadata_write_service.dart +++ b/lib/features/media/data/services/metadata_write_service.dart @@ -5,12 +5,29 @@ import 'package:flutter/services.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; +/// Native error code for an asset the platform cannot edit in place because +/// it is a Live Photo (a still paired with a short video). +/// +/// PhotoKit's content-editing round-trip expects the output to represent both +/// resources, so writing back a bare modified still is rejected with +/// `PHPhotosErrorDomain error 3302`. The iOS and macOS handlers detect the +/// case up front and return this code instead of that raw error. +const metadataWriteLivePhotoUnsupportedCode = 'LIVE_PHOTO_UNSUPPORTED'; + /// Exception thrown when metadata writing fails. class MetadataWriteException implements Exception { final String message; + + /// The originating native error code, when the failure came from the + /// platform channel. Null for failures raised on the Dart side. + /// + /// Callers in the presentation layer use this to substitute a localized + /// message for [message], which is English-only. + final String? code; + final Object? cause; - const MetadataWriteException(this.message, {this.cause}); + const MetadataWriteException(this.message, {this.code, this.cause}); @override String toString() => message; @@ -115,6 +132,9 @@ class DiveMediaMetadata { /// - JPEG photos (EXIF) /// - HEIC/HEIF photos (EXIF via CGImageDestination) /// - MOV/MP4 videos (QuickTime metadata) +/// +/// Does not support Live Photos on iOS or macOS: see +/// [metadataWriteLivePhotoUnsupportedCode]. class MetadataWriteService { static const _channel = MethodChannel('com.submersion.app/metadata'); final _log = LoggerService.forClass(MetadataWriteService); @@ -176,7 +196,11 @@ class MetadataWriteService { } } on PlatformException catch (e) { _log.error('Platform exception writing metadata', error: e); - throw MetadataWriteException(_parseErrorMessage(e), cause: e); + throw MetadataWriteException( + _parseErrorMessage(e), + code: e.code, + cause: e, + ); } catch (e) { _log.error('Unexpected error writing metadata', error: e); throw MetadataWriteException( @@ -202,6 +226,12 @@ class MetadataWriteService { 'Cannot modify iCloud-only or shared album items.'; case 'UNSUPPORTED_FORMAT': return 'This file format does not support metadata writing.'; + case metadataWriteLivePhotoUnsupportedCode: + // Deliberately discards the native message: PhotoKit's own text for + // this case is an untranslated error-domain string. + return 'Live Photos are not supported yet. ' + 'Duplicate this as a still photo, ' + 'then write the dive data to the copy.'; case 'WRITE_FAILED': return message.isNotEmpty ? message : 'Failed to write metadata.'; default: diff --git a/lib/features/media/presentation/pages/media_viewer_page.dart b/lib/features/media/presentation/pages/media_viewer_page.dart index cc56b26abd..f19b14624b 100644 --- a/lib/features/media/presentation/pages/media_viewer_page.dart +++ b/lib/features/media/presentation/pages/media_viewer_page.dart @@ -717,7 +717,13 @@ class _MediaViewerPageState extends ConsumerState { } on MetadataWriteException catch (e) { debugPrint('[MediaViewerPage] MetadataWriteException: ${e.message}'); dismissLoadingDialog(); - _showError(e.message); + // The service's messages are English-only; substitute a translation for + // the codes we have one for and fall back to its text otherwise. + _showError( + e.code == metadataWriteLivePhotoUnsupportedCode + ? l10n.media_writeMetadata_livePhotoUnsupported + : e.message, + ); } catch (e) { debugPrint('[MediaViewerPage] Exception: $e'); dismissLoadingDialog(); diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..0be6c624ed 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -4130,6 +4130,7 @@ "media_writeMetadata_diveTimeLabel": "وقت الغوصة", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "الاحتفاظ بالفيديو الأصلي", + "media_writeMetadata_livePhotoUnsupported": "صور Live Photos غير مدعومة بعد. كرّر هذه الصورة كصورة ثابتة، ثم اكتب بيانات الغوص في النسخة.", "media_writeMetadata_noDataAvailable": "لا توجد بيانات غوص متاحة للكتابة.", "media_writeMetadata_siteLabel": "الموقع", "media_writeMetadata_temperatureLabel": "درجة الحرارة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..3c113006a0 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -4130,6 +4130,7 @@ "media_writeMetadata_diveTimeLabel": "Tauchzeit", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Originalvideo beibehalten", + "media_writeMetadata_livePhotoUnsupported": "Live Photos werden noch nicht unterstützt. Dupliziere dieses Foto als Standbild und schreibe die Tauchdaten dann in die Kopie.", "media_writeMetadata_noDataAvailable": "Keine Tauchdaten zum Schreiben verfügbar.", "media_writeMetadata_siteLabel": "Tauchplatz", "media_writeMetadata_temperatureLabel": "Temperatur", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..9cfedf66a1 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7238,6 +7238,7 @@ "media_writeMetadata_diveTimeLabel": "Dive time", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Keep original video", + "media_writeMetadata_livePhotoUnsupported": "Live Photos are not supported yet. Duplicate this as a still photo, then write the dive data to the copy.", "media_writeMetadata_noDataAvailable": "No dive data available to write.", "media_writeMetadata_siteLabel": "Site", "media_writeMetadata_temperatureLabel": "Temperature", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..bd18b29f91 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -4130,6 +4130,7 @@ "media_writeMetadata_diveTimeLabel": "Hora de inmersion", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Conservar video original", + "media_writeMetadata_livePhotoUnsupported": "Las Live Photos aún no son compatibles. Duplica esta foto como imagen fija y luego escribe los datos de buceo en la copia.", "media_writeMetadata_noDataAvailable": "No hay datos de inmersion disponibles para escribir.", "media_writeMetadata_siteLabel": "Punto", "media_writeMetadata_temperatureLabel": "Temperatura", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..e149f3d7e1 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -4057,6 +4057,7 @@ "media_writeMetadata_diveTimeLabel": "Heure de plongee", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Conserver la video originale", + "media_writeMetadata_livePhotoUnsupported": "Les Live Photos ne sont pas encore prises en charge. Dupliquez cette photo en image fixe, puis écrivez les données de plongée sur la copie.", "media_writeMetadata_noDataAvailable": "Aucune donnee de plongee disponible a ecrire.", "media_writeMetadata_siteLabel": "Site", "media_writeMetadata_temperatureLabel": "Temperature", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..a37b9fe7d9 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -4057,6 +4057,7 @@ "media_writeMetadata_diveTimeLabel": "זמן צלילה", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "שמור סרטון מקורי", + "media_writeMetadata_livePhotoUnsupported": "‏Live Photos עדיין אינן נתמכות. שכפל תמונה זו כתמונת סטילס, ולאחר מכן כתוב את נתוני הצלילה בעותק.", "media_writeMetadata_noDataAvailable": "אין נתוני צלילה זמינים לכתיבה.", "media_writeMetadata_siteLabel": "אתר", "media_writeMetadata_temperatureLabel": "טמפרטורה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..45fc8a1ff1 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -4057,6 +4057,7 @@ "media_writeMetadata_diveTimeLabel": "Merülesi ido", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Eredeti video megtartasa", + "media_writeMetadata_livePhotoUnsupported": "A Live Photo még nem támogatott. Készíts róla állóképes másolatot, majd a merülési adatokat a másolatba írd.", "media_writeMetadata_noDataAvailable": "Nincs elerheto merülesi adat az irashoz.", "media_writeMetadata_siteLabel": "Merülohely", "media_writeMetadata_temperatureLabel": "Homerseklet", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..eadd08d9bb 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -4057,6 +4057,7 @@ "media_writeMetadata_diveTimeLabel": "Tempo di immersione", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Mantieni video originale", + "media_writeMetadata_livePhotoUnsupported": "Le Live Photo non sono ancora supportate. Duplica questa foto come immagine statica, poi scrivi i dati dell'immersione sulla copia.", "media_writeMetadata_noDataAvailable": "Nessun dato immersione disponibile da scrivere.", "media_writeMetadata_siteLabel": "Sito", "media_writeMetadata_temperatureLabel": "Temperatura", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..66d3ec7cfe 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -21946,6 +21946,12 @@ abstract class AppLocalizations { /// **'Keep original video'** String get media_writeMetadata_keepOriginalVideo; + /// No description provided for @media_writeMetadata_livePhotoUnsupported. + /// + /// In en, this message translates to: + /// **'Live Photos are not supported yet. Duplicate this as a still photo, then write the dive data to the copy.'** + String get media_writeMetadata_livePhotoUnsupported; + /// No description provided for @media_writeMetadata_noDataAvailable. /// /// 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..902a802f90 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -12770,6 +12770,10 @@ class AppLocalizationsAr extends AppLocalizations { String get media_writeMetadata_keepOriginalVideo => 'الاحتفاظ بالفيديو الأصلي'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'صور Live Photos غير مدعومة بعد. كرّر هذه الصورة كصورة ثابتة، ثم اكتب بيانات الغوص في النسخة.'; + @override String get media_writeMetadata_noDataAvailable => 'لا توجد بيانات غوص متاحة للكتابة.'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..d3130713e6 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -12999,6 +12999,10 @@ class AppLocalizationsDe extends AppLocalizations { String get media_writeMetadata_keepOriginalVideo => 'Originalvideo beibehalten'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'Live Photos werden noch nicht unterstützt. Dupliziere dieses Foto als Standbild und schreibe die Tauchdaten dann in die Kopie.'; + @override String get media_writeMetadata_noDataAvailable => 'Keine Tauchdaten zum Schreiben verfügbar.'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..7177ab89d3 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -12793,6 +12793,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get media_writeMetadata_keepOriginalVideo => 'Keep original video'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'Live Photos are not supported yet. Duplicate this as a still photo, then write the dive data to the copy.'; + @override String get media_writeMetadata_noDataAvailable => 'No dive data available to write.'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..de58d4b927 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -12994,6 +12994,10 @@ class AppLocalizationsEs extends AppLocalizations { String get media_writeMetadata_keepOriginalVideo => 'Conservar video original'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'Las Live Photos aún no son compatibles. Duplica esta foto como imagen fija y luego escribe los datos de buceo en la copia.'; + @override String get media_writeMetadata_noDataAvailable => 'No hay datos de inmersion disponibles para escribir.'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..21f6ba85b6 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -13046,6 +13046,10 @@ class AppLocalizationsFr extends AppLocalizations { String get media_writeMetadata_keepOriginalVideo => 'Conserver la video originale'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'Les Live Photos ne sont pas encore prises en charge. Dupliquez cette photo en image fixe, puis écrivez les données de plongée sur la copie.'; + @override String get media_writeMetadata_noDataAvailable => 'Aucune donnee de plongee disponible a ecrire.'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..db830ac54e 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -12685,6 +12685,10 @@ class AppLocalizationsHe extends AppLocalizations { @override String get media_writeMetadata_keepOriginalVideo => 'שמור סרטון מקורי'; + @override + String get media_writeMetadata_livePhotoUnsupported => + '‏Live Photos עדיין אינן נתמכות. שכפל תמונה זו כתמונת סטילס, ולאחר מכן כתוב את נתוני הצלילה בעותק.'; + @override String get media_writeMetadata_noDataAvailable => 'אין נתוני צלילה זמינים לכתיבה.'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..b79367bbbd 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -12967,6 +12967,10 @@ class AppLocalizationsHu extends AppLocalizations { String get media_writeMetadata_keepOriginalVideo => 'Eredeti video megtartasa'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'A Live Photo még nem támogatott. Készíts róla állóképes másolatot, majd a merülési adatokat a másolatba írd.'; + @override String get media_writeMetadata_noDataAvailable => 'Nincs elerheto merülesi adat az irashoz.'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..7ce7908a60 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -13009,6 +13009,10 @@ class AppLocalizationsIt extends AppLocalizations { String get media_writeMetadata_keepOriginalVideo => 'Mantieni video originale'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'Le Live Photo non sono ancora supportate. Duplica questa foto come immagine statica, poi scrivi i dati dell\'immersione sulla copia.'; + @override String get media_writeMetadata_noDataAvailable => 'Nessun dato immersione disponibile da scrivere.'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..ad3c42bbe2 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -12909,6 +12909,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get media_writeMetadata_keepOriginalVideo => 'Originele video bewaren'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'Live Photos worden nog niet ondersteund. Dupliceer deze als stilstaande foto en schrijf de duikgegevens vervolgens naar de kopie.'; + @override String get media_writeMetadata_noDataAvailable => 'Geen duikgegevens beschikbaar om te schrijven.'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..41ba5d0440 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -13006,6 +13006,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get media_writeMetadata_keepOriginalVideo => 'Manter video original'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'As Live Photos ainda não são suportadas. Duplique esta como fotografia estática e escreva os dados do mergulho na cópia.'; + @override String get media_writeMetadata_noDataAvailable => 'Nenhum dado de mergulho disponivel para gravar.'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..1799b44895 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -12408,6 +12408,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get media_writeMetadata_keepOriginalVideo => '保留原始视频'; + @override + String get media_writeMetadata_livePhotoUnsupported => + '尚不支持实况照片。请将其复制为静态照片,然后将潜水数据写入副本。'; + @override String get media_writeMetadata_noDataAvailable => '没有可写入的潜水数据。'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..1f1b59fd32 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -4130,6 +4130,7 @@ "media_writeMetadata_diveTimeLabel": "Duiktijd", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Originele video bewaren", + "media_writeMetadata_livePhotoUnsupported": "Live Photos worden nog niet ondersteund. Dupliceer deze als stilstaande foto en schrijf de duikgegevens vervolgens naar de kopie.", "media_writeMetadata_noDataAvailable": "Geen duikgegevens beschikbaar om te schrijven.", "media_writeMetadata_siteLabel": "Duikstek", "media_writeMetadata_temperatureLabel": "Temperatuur", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..1be9b90942 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -4130,6 +4130,7 @@ "media_writeMetadata_diveTimeLabel": "Horario do mergulho", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "Manter video original", + "media_writeMetadata_livePhotoUnsupported": "As Live Photos ainda não são suportadas. Duplique esta como fotografia estática e escreva os dados do mergulho na cópia.", "media_writeMetadata_noDataAvailable": "Nenhum dado de mergulho disponivel para gravar.", "media_writeMetadata_siteLabel": "Ponto", "media_writeMetadata_temperatureLabel": "Temperatura", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..f7e8524d78 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -4285,6 +4285,7 @@ "media_writeMetadata_diveTimeLabel": "潜水时间", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "保留原始视频", + "media_writeMetadata_livePhotoUnsupported": "尚不支持实况照片。请将其复制为静态照片,然后将潜水数据写入副本。", "media_writeMetadata_noDataAvailable": "没有可写入的潜水数据。", "media_writeMetadata_siteLabel": "潜水点", "media_writeMetadata_temperatureLabel": "温度", diff --git a/macos/Runner/MetadataWriteHandler.swift b/macos/Runner/MetadataWriteHandler.swift index bd3177dc43..0c4326c931 100644 --- a/macos/Runner/MetadataWriteHandler.swift +++ b/macos/Runner/MetadataWriteHandler.swift @@ -97,6 +97,18 @@ class MetadataWriteHandler: NSObject { if isVideo { writeVideoMetadata(asset: asset, metadata: metadata, description: description, keepOriginal: keepOriginal, result: result) + } else if asset.mediaSubtypes.contains(.photoLive) { + // A Live Photo is a still paired with a short video. Photos' content + // editing session expects the output to represent both resources, so + // the plain-image round-trip in writePhotoMetadata is rejected with + // PHPhotosErrorDomain error 3302. Refuse up front with a code the + // Dart layer can translate rather than surfacing that raw error. + NSLog("[MetadataWriteHandler] Asset is a Live Photo; metadata writing is not supported") + result(FlutterError( + code: "LIVE_PHOTO_UNSUPPORTED", + message: "Live Photos cannot be edited in place without breaking the paired video.", + details: nil + )) } else { writePhotoMetadata(asset: asset, metadata: metadata, description: description, result: result) } diff --git a/test/features/media/data/services/metadata_write_service_test.dart b/test/features/media/data/services/metadata_write_service_test.dart new file mode 100644 index 0000000000..c92ea32ba0 --- /dev/null +++ b/test/features/media/data/services/metadata_write_service_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/metadata_write_service.dart'; + +/// Mirrors `MetadataWriteService.isSupported`, which is checked BEFORE the +/// platform channel: on an unsupported host the service throws outright and +/// the mocked channel is never reached, so every case that asserts on a +/// channel response has to be skipped there. +final bool _metadataWriteSupported = + Platform.isIOS || Platform.isMacOS || Platform.isAndroid; + +/// Enough dive data to clear the service's `hasData` guard. +const _metadata = DiveMediaMetadata( + depthMeters: 18.3, + temperatureCelsius: 21.5, + latitude: 36.9, + longitude: -25.1, + siteName: 'Dom Pedro', + elapsedSeconds: 600, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('com.submersion.app/metadata'); + late Future Function(MethodCall) handler; + + setUp(() { + handler = (_) async => true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) => handler(call)); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future write() => MetadataWriteService().writeMetadata( + platformAssetId: 'asset-1', + metadata: _metadata, + isVideo: false, + ); + + group('live photos', () { + test('the native refusal is carried through as a distinct code', () async { + handler = (_) async => throw PlatformException( + code: metadataWriteLivePhotoUnsupportedCode, + message: 'Live Photos are not supported.', + ); + + await expectLater( + write(), + throwsA( + isA().having( + (e) => e.code, + 'code', + metadataWriteLivePhotoUnsupportedCode, + ), + ), + ); + }, skip: !_metadataWriteSupported); + + test('the raw PhotoKit error never reaches the message', () async { + // Before the native Live Photo check existed, PhotoKit rejected the + // rewritten still and its untranslated error text was shown verbatim. + handler = (_) async => throw PlatformException( + code: metadataWriteLivePhotoUnsupportedCode, + message: + "The operation couldn't be completed. " + '(PHPhotosErrorDomain error 3302.)', + ); + + await expectLater( + write(), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('Live Photo'), + isNot(contains('PHPhotosErrorDomain')), + ), + ), + ), + ); + }, skip: !_metadataWriteSupported); + }); + + group('other platform failures', () { + test('a known code keeps its curated message and its code', () async { + handler = (_) async => + throw PlatformException(code: 'READ_ONLY', message: 'raw native'); + + await expectLater( + write(), + throwsA( + isA() + .having((e) => e.code, 'code', 'READ_ONLY') + .having((e) => e.message, 'message', contains('read-only')), + ), + ); + }, skip: !_metadataWriteSupported); + + test('WRITE_FAILED still passes the native message through', () async { + handler = (_) async => + throw PlatformException(code: 'WRITE_FAILED', message: 'disk full'); + + await expectLater( + write(), + throwsA( + isA() + .having((e) => e.code, 'code', 'WRITE_FAILED') + .having((e) => e.message, 'message', 'disk full'), + ), + ); + }, skip: !_metadataWriteSupported); + + test('a failure raised on the Dart side carries no code', () async { + // A `false` result is rejected by a throw inside the service's own try, + // so it reaches the untyped catch rather than the PlatformException one + // and no native code is available to attach. Callers keying off `code` + // must therefore tolerate null and fall back to the message. + handler = (_) async => false; + + await expectLater( + write(), + throwsA( + isA().having((e) => e.code, 'code', isNull), + ), + ); + }, skip: !_metadataWriteSupported); + + test('a thrown non-platform error is still surfaced', () async { + // The mock messenger re-wraps anything that is not a PlatformException + // into `PlatformException(code: 'error')`, so this arrives typed. + handler = (_) async => throw StateError('boom'); + + await expectLater(write(), throwsA(isA())); + }, skip: !_metadataWriteSupported); + }); +} diff --git a/test/features/media/presentation/pages/media_viewer_write_metadata_test.dart b/test/features/media/presentation/pages/media_viewer_write_metadata_test.dart index ebf109784d..cf8bb2e545 100644 --- a/test/features/media/presentation/pages/media_viewer_write_metadata_test.dart +++ b/test/features/media/presentation/pages/media_viewer_write_metadata_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/media/data/services/media_source_resolver_registry.dart'; +import 'package:submersion/features/media/data/services/metadata_write_service.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; import 'package:submersion/features/media/domain/services/media_source_resolver.dart'; @@ -231,4 +232,26 @@ void main() { expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.byType(SnackBar), findsOneWidget); }); + + testWidgets('a Live Photo refusal is translated, not shown raw', ( + tester, + ) async { + // The native handlers now reject Live Photos up front; before that, + // PhotoKit rejected the rewritten still and this untranslated string + // reached the user verbatim (issue #795). + handler = (_) async => throw PlatformException( + code: metadataWriteLivePhotoUnsupportedCode, + message: + "The operation couldn't be completed. " + '(PHPhotosErrorDomain error 3302.)', + ); + await pump(tester, item()); + await openDialog(tester); + await confirmWrite(tester); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.textContaining('PHPhotosErrorDomain'), findsNothing); + expect(find.textContaining('Live Photos'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }, skip: !_metadataWriteSupported); } From f1db8761c6a1b525d3956695a76df86be1603314 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:42:38 -0400 Subject: [PATCH 067/122] feat(sites): backfill service for missing location details (#1187) --- .../site_location_backfill_service.dart | 128 +++++++++++ .../site_location_backfill_service_test.dart | 215 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 lib/features/dive_sites/domain/services/site_location_backfill_service.dart create mode 100644 test/features/dive_sites/domain/services/site_location_backfill_service_test.dart diff --git a/lib/features/dive_sites/domain/services/site_location_backfill_service.dart b/lib/features/dive_sites/domain/services/site_location_backfill_service.dart new file mode 100644 index 0000000000..a9f3dff890 --- /dev/null +++ b/lib/features/dive_sites/domain/services/site_location_backfill_service.dart @@ -0,0 +1,128 @@ +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +/// Outcome of one backfill run. +class BackfillSummary { + const BackfillSummary({ + required this.total, + required this.updated, + required this.unchanged, + required this.failed, + this.cancelled = false, + this.offline = false, + }); + + final int total; + final int updated; + final int unchanged; + final int failed; + final bool cancelled; + + /// The geocoder could not be reached on the first request, so the run + /// stopped before collecting one failure per site. + final bool offline; +} + +bool _isBlank(String? value) => value == null || value.trim().isEmpty; + +/// Fills empty country, region, town and body of water for every site that +/// has coordinates (issue #1187). Only empty columns are ever written; the +/// rule itself lives in `mergeMissingLocationDetails` behind +/// [SiteRepository.fillMissingLocationDetails]. Request spacing is the +/// location service's concern. +class SiteLocationBackfillService { + SiteLocationBackfillService({ + required SiteRepository sites, + required LocationService location, + required String languageCode, + }) : _sites = sites, + _location = location, + _languageCode = languageCode; + + final SiteRepository _sites; + final LocationService _location; + final String _languageCode; + static final _log = LoggerService.forClass(SiteLocationBackfillService); + + /// A site the run would look up: coordinates present and at least one of + /// the four fields blank. + static bool needsLookup(DiveSite site) => + site.location != null && + (_isBlank(site.country) || + _isBlank(site.region) || + _isBlank(site.city) || + _isBlank(site.bodyOfWater)); + + Future> candidates({String? diverId}) async { + final all = await _sites.getAllSites(diverId: diverId); + return all.where(needsLookup).toList(growable: false); + } + + Future run({ + String? diverId, + required void Function(int done, int total) onProgress, + required bool Function() isCancelled, + }) async { + final targets = await candidates(diverId: diverId); + final total = targets.length; + var updated = 0; + var unchanged = 0; + var failed = 0; + var done = 0; + onProgress(done, total); + + for (final site in targets) { + if (isCancelled()) { + return BackfillSummary( + total: total, + updated: updated, + unchanged: unchanged, + failed: failed, + cancelled: true, + ); + } + final point = site.location!; + try { + final lookup = await _location.reverseGeocode( + point.latitude, + point.longitude, + languageCode: _languageCode, + ); + if (lookup.networkFailed) { + if (done == 0) { + return BackfillSummary( + total: total, + updated: updated, + unchanged: unchanged, + failed: failed, + offline: true, + ); + } + failed++; + } else if (await _sites.fillMissingLocationDetails(site.id, lookup)) { + updated++; + } else { + unchanged++; + } + } catch (e, stackTrace) { + _log.warning( + 'Backfill failed for site ${site.id}: $e', + error: e, + stackTrace: stackTrace, + ); + failed++; + } + done++; + onProgress(done, total); + } + + return BackfillSummary( + total: total, + updated: updated, + unchanged: unchanged, + failed: failed, + ); + } +} diff --git a/test/features/dive_sites/domain/services/site_location_backfill_service_test.dart b/test/features/dive_sites/domain/services/site_location_backfill_service_test.dart new file mode 100644 index 0000000000..64f695b0af --- /dev/null +++ b/test/features/dive_sites/domain/services/site_location_backfill_service_test.dart @@ -0,0 +1,215 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_backfill_service.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Answers each coordinate from a map; unknown coordinates come back empty. +class _MapLocationService implements LocationService { + _MapLocationService(this.answers, {this.offline = false, this.throwOn}); + + final Map answers; + final bool offline; + final String? throwOn; + final List asked = []; + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async { + final key = '$latitude,$longitude'; + asked.add(key); + if (offline) return const PlaceLookup.unavailable(); + if (key == throwOn) throw StateError('boom'); + return answers[key] ?? const PlaceLookup.empty(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late SiteRepository sites; + + setUp(() async { + await setUpTestDatabase(); + sites = SiteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + const weggis = PlaceLookup( + country: 'Switzerland', + region: 'Lucerne', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', + ); + + Future seed() async { + await sites.createSite( + const DiveSite(id: 'empty', name: 'Empty', location: GeoPoint(47.0, 8.4)), + ); + await sites.createSite( + const DiveSite( + id: 'partial', + name: 'Partial', + country: 'Switzerland', + region: 'Lucerne', + location: GeoPoint(47.1, 8.5), + ), + ); + await sites.createSite( + const DiveSite( + id: 'full', + name: 'Full', + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + location: GeoPoint(47.2, 8.6), + ), + ); + await sites.createSite(const DiveSite(id: 'nogps', name: 'No GPS')); + } + + SiteLocationBackfillService service(LocationService location) => + SiteLocationBackfillService( + sites: sites, + location: location, + languageCode: 'en', + ); + + test('needsLookup wants coordinates and at least one empty field', () { + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite(id: '1', name: 'n', location: GeoPoint(1, 2)), + ), + isTrue, + ); + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite(id: '1', name: 'n'), + ), + isFalse, + ); + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite( + id: '1', + name: 'n', + location: GeoPoint(1, 2), + country: 'a', + region: 'b', + city: 'c', + bodyOfWater: 'd', + ), + ), + isFalse, + ); + expect( + SiteLocationBackfillService.needsLookup( + const DiveSite( + id: '1', + name: 'n', + location: GeoPoint(1, 2), + country: 'a', + region: 'b', + city: ' ', + bodyOfWater: 'd', + ), + ), + isTrue, + reason: 'blank counts as empty', + ); + }); + + test('candidates skips full sites and sites without coordinates', () async { + await seed(); + final found = await service(_MapLocationService({})).candidates(); + expect(found.map((s) => s.id), unorderedEquals(['empty', 'partial'])); + }); + + test('run fills only empty fields and counts outcomes', () async { + await seed(); + final location = _MapLocationService({ + '47.0,8.4': weggis, + '47.1,8.5': const PlaceLookup(country: 'Schweiz', locality: 'Weggis'), + }); + final progress = <(int, int)>[]; + + final summary = await service(location).run( + onProgress: (done, total) => progress.add((done, total)), + isCancelled: () => false, + ); + + expect(summary.total, 2); + expect(summary.updated, 2); + expect(summary.unchanged, 0); + expect(summary.failed, 0); + expect(summary.cancelled, isFalse); + expect(progress, [(0, 2), (1, 2), (2, 2)]); + expect(location.asked, hasLength(2), reason: 'full and nogps not asked'); + + final partial = await sites.getSiteById('partial'); + expect(partial!.country, 'Switzerland', reason: 'kept'); + expect(partial.city, 'Weggis'); + final empty = await sites.getSiteById('empty'); + expect(empty!.bodyOfWater, 'Lake Lucerne'); + }); + + test('a lookup that finds nothing counts as unchanged', () async { + await seed(); + final summary = await service( + _MapLocationService({}), + ).run(onProgress: (_, _) {}, isCancelled: () => false); + expect(summary.updated, 0); + expect(summary.unchanged, 2); + }); + + test('a throwing site is counted as failed and the run continues', () async { + await seed(); + final location = _MapLocationService({ + '47.1,8.5': weggis, + }, throwOn: '47.0,8.4'); + + final summary = await service( + location, + ).run(onProgress: (_, _) {}, isCancelled: () => false); + + expect(summary.failed, 1); + expect(summary.updated, 1); + }); + + test('cancelling between sites stops the run', () async { + await seed(); + final location = _MapLocationService({'47.0,8.4': weggis}); + var calls = 0; + + final summary = await service( + location, + ).run(onProgress: (_, _) {}, isCancelled: () => calls++ >= 1); + + expect(summary.cancelled, isTrue); + expect(location.asked, hasLength(1)); + }); + + test('an unreachable geocoder on the first site aborts as offline', () async { + await seed(); + final location = _MapLocationService({}, offline: true); + + final summary = await service( + location, + ).run(onProgress: (_, _) {}, isCancelled: () => false); + + expect(summary.offline, isTrue); + expect(summary.failed, 0); + expect(location.asked, hasLength(1)); + }); +} From 069e2b22d681a09a1396d8aa8efeb12bc03d6717 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:44:20 -0400 Subject: [PATCH 068/122] fix: address review on the gauge estimated-pressure change Drop an orphaned comment in _applyDiverSettingDefaults. Removing the seed line left its two-line v163 comment stranded above an unrelated key, which read as a missing entry. The seed itself stays out: upsertRecord and the batch path both run _withSchemaDefaults first, which fills any missing NOT NULL column from its declared Drift default, so a column carrying withDefault(Constant(true)) already hydrates from a payload that omits the key. Verified by deleting the line and re-running the mixed-version test, which still passes, and the same holds for the existing defaultShowO2CellMv seed. Pin Locale('en') in the default visible metrics widget tests. Every finder there matches an English label while flutter_test forwards the host machine's locale, so the file was locale-dependent. Confirmed by switching the pin to 'de', which fails all eight tests, the six pre-existing ones included. Mirrors c5cc716 for the dive site depth unit tests. --- lib/core/services/sync/sync_data_serializer.dart | 2 -- .../presentation/pages/default_visible_metrics_page_test.dart | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index cb8e15b520..a942e36284 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -5663,8 +5663,6 @@ class SyncDataSerializer { // v161: seed it so payloads predating the column hydrate instead of // throwing in DiverSetting.fromJson. 'defaultShowO2CellMv': false, - // v163: seed it so payloads predating the column hydrate instead of - // throwing in DiverSetting.fromJson (issue #731). // Dive profile default-visible metrics. Non-nullable bool added in v91; // seed it so payloads predating the column hydrate instead of throwing in // DiverSetting.fromJson. diff --git a/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart b/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart index 8a752b5213..e6c9363f2b 100644 --- a/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart +++ b/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart @@ -44,6 +44,10 @@ void main() { child: const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + // Every finder below matches an English label, so pin the locale + // rather than depend on the host machine's, which flutter_test + // forwards. + locale: Locale('en'), home: DefaultVisibleMetricsPage(), ), ); From e7b6e8d217ec2eb674c42976ebf65b4a9449dde5 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:44:39 -0400 Subject: [PATCH 069/122] perf(buddies): stabilize shared-dive ordering and index dive_buddies.buddy_id Addresses the three suppressed findings on #1294. Deterministic ordering: the ORDER BY had no tiebreak once two dives tied on both effective timestamp and dive number, so SQLite was free to return them in any order. Because the caller truncates to a five-dive preview, an unstable tail changes WHICH dives are shown, not merely their order, which is the same class of bug this PR set out to fix. A final tiebreak on d.id makes it a contract. The Dart re-sort in divesForBuddyProvider was given the matching id tiebreak so a tie resolves the same way in both places. buddy_id index: getDiveIdsForBuddy, getDiveCountForBuddy and addBuddyToDive's existing-row check all filter dive_buddies on buddy_id, which only had an index on dive_id and so scanned the link table. Added as a (buddy_id, dive_id) covering index in kPerformanceIndexes, which is asserted idempotently from beforeOpen and therefore needs no schema version bump. The two interact, which is worth recording. Measured on the new test: with neither change the query returns rowid order; with the index but no tiebreak it returns id order purely because scanning (buddy_id, dive_id) yields dive_id ascending. So the ordering was previously an accident of the chosen query plan, and would have flipped silently if the index were ever changed or dropped. The explicit tiebreak removes that coupling. --- lib/core/database/performance_indexes.dart | 9 +++++++ .../data/repositories/buddy_repository.dart | 9 +++++-- .../providers/buddy_providers.dart | 8 ++++-- .../repositories/buddy_repository_test.dart | 25 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/core/database/performance_indexes.dart b/lib/core/database/performance_indexes.dart index 9d84f3aac8..6588770e02 100644 --- a/lib/core/database/performance_indexes.dart +++ b/lib/core/database/performance_indexes.dart @@ -129,6 +129,15 @@ const List kPerformanceIndexes = [ 'CREATE INDEX IF NOT EXISTS idx_dive_buddies_dive_id ' 'ON dive_buddies(dive_id)', ), + // The reverse direction: getDiveIdsForBuddy, getDiveCountForBuddy and + // addBuddyToDive's existing-row check all filter on buddy_id, which had no + // index and scanned the link table. + ( + name: 'idx_dive_buddies_buddy_id', + ddl: + 'CREATE INDEX IF NOT EXISTS idx_dive_buddies_buddy_id ' + 'ON dive_buddies(buddy_id, dive_id)', + ), ( name: 'idx_dive_custom_fields_dive_id', ddl: diff --git a/lib/features/buddies/data/repositories/buddy_repository.dart b/lib/features/buddies/data/repositories/buddy_repository.dart index 5511130d78..89db094cbe 100644 --- a/lib/features/buddies/data/repositories/buddy_repository.dart +++ b/lib/features/buddies/data/repositories/buddy_repository.dart @@ -835,7 +835,11 @@ class BuddyRepository { /// `dive_buddies` link row was written, so callers that truncate the result /// (the detail page previews the first five) get the newest dives and not an /// arbitrary slice of the import order. The sort key mirrors - /// `DiveRepository.getAllDives` so the preview agrees with the dive list. + /// `DiveRepository.getAllDives` so the preview agrees with the dive list, + /// with a final tiebreak on id so dives that tie on both keys keep a stable + /// order instead of an arbitrary one: the caller truncates this list, so an + /// unstable tail would change *which* dives the preview shows, not merely + /// their order. /// The join also drops links whose dive row no longer exists. Future> getDiveIdsForBuddy(String buddyId) async { final results = await _db @@ -846,7 +850,8 @@ class BuddyRepository { INNER JOIN dives d ON d.id = db.dive_id WHERE db.buddy_id = ? ORDER BY COALESCE(d.entry_time, d.dive_date_time) DESC, - d.dive_number DESC + d.dive_number DESC, + d.id ''', variables: [Variable.withString(buddyId)], ) diff --git a/lib/features/buddies/presentation/providers/buddy_providers.dart b/lib/features/buddies/presentation/providers/buddy_providers.dart index 7eb7747e70..d44ff7d76f 100644 --- a/lib/features/buddies/presentation/providers/buddy_providers.dart +++ b/lib/features/buddies/presentation/providers/buddy_providers.dart @@ -216,11 +216,15 @@ final divesForBuddyProvider = FutureProvider.family, String>(( } } - // Most recent first, matching the dive list's sort key. + // Most recent first, matching the dive list's sort key. The id tiebreak + // mirrors the repository query so a tie on both keys resolves the same way + // here as it does in SQL. dives.sort((a, b) { final byTime = b.effectiveEntryTime.compareTo(a.effectiveEntryTime); if (byTime != 0) return byTime; - return (b.diveNumber ?? 0).compareTo(a.diveNumber ?? 0); + final byNumber = (b.diveNumber ?? 0).compareTo(a.diveNumber ?? 0); + if (byNumber != 0) return byNumber; + return a.id.compareTo(b.id); }); return dives; }); diff --git a/test/features/buddies/data/repositories/buddy_repository_test.dart b/test/features/buddies/data/repositories/buddy_repository_test.dart index 51d0642b47..522902fd8c 100644 --- a/test/features/buddies/data/repositories/buddy_repository_test.dart +++ b/test/features/buddies/data/repositories/buddy_repository_test.dart @@ -510,6 +510,31 @@ void main() { expect(diveIds, equals(['higher', 'lower'])); }, ); + + test( + 'is deterministic when timestamp and dive number both tie', + () async { + final buddy = await repository.createBuddy(createTestBuddy(id: 'b1')); + // Same instant, no dive number: only the id can separate these. They + // are inserted in reverse id order so a query with no id tiebreak + // returns them in insertion order instead. + await insertDive('zzz', diveDateTime: 1000); + await insertDive('aaa', diveDateTime: 1000); + for (final id in ['zzz', 'aaa']) { + await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); + } + + final diveIds = await repository.getDiveIdsForBuddy(buddy.id); + + expect( + diveIds, + equals(['aaa', 'zzz']), + reason: + 'the caller truncates this list, so an unstable tail would ' + 'change which dives the preview shows, not just their order', + ); + }, + ); }); }); } From 16fef7178d8713652bd20cc79801bd7bcdf628bb Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:50:51 -0400 Subject: [PATCH 070/122] feat(sites): bulk fill of missing location details from the sites list (#1187) --- .../presentation/pages/site_list_page.dart | 14 ++ .../site_location_backfill_provider.dart | 76 ++++++++ .../widgets/site_list_content.dart | 27 +++ .../site_location_backfill_dialog.dart | 124 ++++++++++++ lib/l10n/arb/app_ar.arb | 10 + lib/l10n/arb/app_de.arb | 10 + lib/l10n/arb/app_en.arb | 43 ++++ lib/l10n/arb/app_es.arb | 10 + lib/l10n/arb/app_fr.arb | 10 + lib/l10n/arb/app_he.arb | 10 + lib/l10n/arb/app_hu.arb | 10 + lib/l10n/arb/app_it.arb | 10 + lib/l10n/arb/app_localizations.dart | 60 ++++++ lib/l10n/arb/app_localizations_ar.dart | 47 +++++ lib/l10n/arb/app_localizations_de.dart | 48 +++++ lib/l10n/arb/app_localizations_en.dart | 48 +++++ lib/l10n/arb/app_localizations_es.dart | 49 +++++ lib/l10n/arb/app_localizations_fr.dart | 49 +++++ lib/l10n/arb/app_localizations_he.dart | 45 +++++ lib/l10n/arb/app_localizations_hu.dart | 48 +++++ lib/l10n/arb/app_localizations_it.dart | 49 +++++ lib/l10n/arb/app_localizations_nl.dart | 48 +++++ lib/l10n/arb/app_localizations_pt.dart | 49 +++++ lib/l10n/arb/app_localizations_zh.dart | 42 ++++ lib/l10n/arb/app_nl.arb | 10 + lib/l10n/arb/app_pt.arb | 10 + lib/l10n/arb/app_zh.arb | 10 + .../site_location_backfill_provider_test.dart | 183 ++++++++++++++++++ .../site_location_backfill_dialog_test.dart | 181 +++++++++++++++++ 29 files changed, 1330 insertions(+) create mode 100644 lib/features/dive_sites/presentation/providers/site_location_backfill_provider.dart create mode 100644 lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart create mode 100644 test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart create mode 100644 test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart diff --git a/lib/features/dive_sites/presentation/pages/site_list_page.dart b/lib/features/dive_sites/presentation/pages/site_list_page.dart index a4a0b19ee1..7f138fe3d2 100644 --- a/lib/features/dive_sites/presentation/pages/site_list_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_list_page.dart @@ -9,6 +9,7 @@ import 'package:submersion/core/models/sort_state.dart'; import 'package:submersion/features/dive_sites/domain/constants/site_field.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; import 'package:submersion/features/dive_sites/presentation/widgets/site_filter_sheet.dart'; +import 'package:submersion/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/widgets/entity_table/entity_table_column_picker.dart'; import 'package:submersion/shared/widgets/list_view_mode_toggle.dart'; @@ -178,6 +179,8 @@ class _SiteListPageState extends ConsumerState { value.replaceFirst('view_', ''), ); ref.read(siteListViewModeProvider.notifier).state = mode; + } else if (value == 'fill_location_details') { + showSiteLocationBackfillFlow(context, ref); } }, itemBuilder: (context) { @@ -192,6 +195,17 @@ class _SiteListPageState extends ConsumerState { ListViewMode.table, ], ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'fill_location_details', + child: ListTile( + leading: const Icon(Icons.travel_explore), + title: Text( + context.l10n.diveSites_list_menu_fillLocationDetails, + ), + contentPadding: EdgeInsets.zero, + ), + ), ]; }, ), diff --git a/lib/features/dive_sites/presentation/providers/site_location_backfill_provider.dart b/lib/features/dive_sites/presentation/providers/site_location_backfill_provider.dart new file mode 100644 index 0000000000..389d819d38 --- /dev/null +++ b/lib/features/dive_sites/presentation/providers/site_location_backfill_provider.dart @@ -0,0 +1,76 @@ +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_backfill_service.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// Progress of the bulk location-details backfill (issue #1187). +sealed class BackfillState { + const BackfillState(); +} + +class BackfillIdle extends BackfillState { + const BackfillIdle(); +} + +class BackfillRunning extends BackfillState { + const BackfillRunning({required this.done, required this.total}); + final int done; + final int total; +} + +class BackfillFinished extends BackfillState { + const BackfillFinished(this.summary); + final BackfillSummary summary; +} + +/// Owns one backfill run at a time so the progress dialog can be rebuilt, +/// dismissed and reopened without losing the run. +class SiteLocationBackfillNotifier extends StateNotifier { + SiteLocationBackfillNotifier(this._ref) : super(const BackfillIdle()); + + final Ref _ref; + bool _cancelRequested = false; + + SiteLocationBackfillService _service() => SiteLocationBackfillService( + sites: _ref.read(siteRepositoryProvider), + location: _ref.read(locationServiceProvider), + languageCode: _ref.read(placeNameLanguageProvider), + ); + + Future _diverId() => + _ref.read(validatedCurrentDiverIdProvider.future); + + /// How many sites a run would look up. + Future countCandidates() async => + (await _service().candidates(diverId: await _diverId())).length; + + /// Starts a run unless one is already running. + Future start() async { + if (state is BackfillRunning) return; + _cancelRequested = false; + state = const BackfillRunning(done: 0, total: 0); + final summary = await _service().run( + diverId: await _diverId(), + onProgress: (done, total) { + if (mounted) state = BackfillRunning(done: done, total: total); + }, + isCancelled: () => _cancelRequested, + ); + if (!mounted) return; + state = BackfillFinished(summary); + if (summary.updated > 0) { + await _ref.read(siteListNotifierProvider.notifier).refresh(); + } + } + + void cancel() => _cancelRequested = true; + + void reset() => state = const BackfillIdle(); +} + +final siteLocationBackfillProvider = + StateNotifierProvider( + (ref) => SiteLocationBackfillNotifier(ref), + ); diff --git a/lib/features/dive_sites/presentation/widgets/site_list_content.dart b/lib/features/dive_sites/presentation/widgets/site_list_content.dart index 07b9a7f199..2df3cd80af 100644 --- a/lib/features/dive_sites/presentation/widgets/site_list_content.dart +++ b/lib/features/dive_sites/presentation/widgets/site_list_content.dart @@ -34,6 +34,7 @@ import 'package:submersion/features/dive_sites/presentation/providers/site_provi import 'package:submersion/features/dive_sites/presentation/widgets/compact_site_list_tile.dart'; import 'package:submersion/features/dive_sites/presentation/widgets/dense_site_list_tile.dart'; import 'package:submersion/features/dive_sites/presentation/widgets/site_filter_sheet.dart'; +import 'package:submersion/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart'; import 'package:submersion/shared/selection/selection_leading.dart'; import 'package:submersion/shared/widgets/debounced_search_results.dart'; import 'package:submersion/shared/widgets/feature_accent.dart'; @@ -519,6 +520,8 @@ class _SiteListContentState extends ConsumerState { _selection.enterExplicit(); } else if (value == 'import') { context.push('/sites/import'); + } else if (value == 'fill_location_details') { + showSiteLocationBackfillFlow(context, ref); } else if (value.startsWith('view_')) { final mode = ListViewMode.fromName( value.replaceFirst('view_', ''), @@ -560,6 +563,18 @@ class _SiteListContentState extends ConsumerState { contentPadding: EdgeInsets.zero, ), ), + PopupMenuItem( + value: 'fill_location_details', + child: ListTile( + leading: const Icon(Icons.travel_explore), + title: Text( + context + .l10n + .diveSites_list_menu_fillLocationDetails, + ), + contentPadding: EdgeInsets.zero, + ), + ), ]; }, ), @@ -781,6 +796,8 @@ class _SiteListContentState extends ConsumerState { _selection.enterExplicit(); } else if (value == 'import') { context.push('/sites/import'); + } else if (value == 'fill_location_details') { + showSiteLocationBackfillFlow(context, ref); } else if (value.startsWith('view_')) { final mode = ListViewMode.fromName( value.replaceFirst('view_', ''), @@ -809,6 +826,16 @@ class _SiteListContentState extends ConsumerState { value: 'import', child: Text(context.l10n.diveSites_list_menu_import), ), + PopupMenuItem( + value: 'fill_location_details', + child: ListTile( + leading: const Icon(Icons.travel_explore), + title: Text( + context.l10n.diveSites_list_menu_fillLocationDetails, + ), + contentPadding: EdgeInsets.zero, + ), + ), ]; }, ), diff --git a/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart b/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart new file mode 100644 index 0000000000..ab479626bb --- /dev/null +++ b/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_location_backfill_provider.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Seconds per site: two Nominatim requests, one second apart. +const int _secondsPerSite = 2; + +/// The bulk "fill in missing location details" flow (issue #1187): +/// count, confirm, run with a progress dialog, summarise in a snackbar. +Future showSiteLocationBackfillFlow( + BuildContext context, + WidgetRef ref, +) async { + final l10n = context.l10n; + final notifier = ref.read(siteLocationBackfillProvider.notifier); + final messenger = ScaffoldMessenger.of(context); + + final count = await notifier.countCandidates(); + if (!context.mounted) return; + if (count == 0) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.diveSites_backfill_nothingToFill)), + ); + return; + } + + final minutes = ((count * _secondsPerSite) / 60).ceil(); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.diveSites_backfill_confirm_title), + content: Text(l10n.diveSites_backfill_confirm_body(count, minutes)), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.diveSites_backfill_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.diveSites_backfill_confirm_start), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + notifier.reset(); + final run = notifier.start(); + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _BackfillProgressDialog(), + ); + await run; + if (!context.mounted) return; + + final state = ref.read(siteLocationBackfillProvider); + if (state is! BackfillFinished) return; + final summary = state.summary; + messenger.showSnackBar( + SnackBar( + content: Text( + summary.offline + ? l10n.diveSites_backfill_offline + : l10n.diveSites_backfill_summary( + summary.updated, + summary.unchanged, + summary.failed, + ), + ), + ), + ); + notifier.reset(); +} + +/// Watches the run and closes itself when it finishes. +class _BackfillProgressDialog extends ConsumerWidget { + const _BackfillProgressDialog(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final state = ref.watch(siteLocationBackfillProvider); + + ref.listen(siteLocationBackfillProvider, (_, next) { + if (next is BackfillFinished && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }); + if (state is BackfillFinished) { + // The run finished before this dialog's first listen could fire. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }); + } + + final running = state is BackfillRunning ? state : null; + final total = running?.total ?? 0; + final done = running?.done ?? 0; + return AlertDialog( + title: Text(l10n.diveSites_backfill_progress_title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator(value: total == 0 ? null : done / total), + const SizedBox(height: 12), + Text(l10n.diveSites_backfill_progress_count(done, total)), + ], + ), + actions: [ + TextButton( + onPressed: () => + ref.read(siteLocationBackfillProvider.notifier).cancel(), + child: Text(l10n.diveSites_backfill_cancel), + ), + ], + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 685568bf82..b034309dbb 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "العملة الافتراضية", "settings_units_dialog_defaultCurrency": "العملة الافتراضية", "diveSites_list_menu_select": "تحديد المواقع", + "diveSites_list_menu_fillLocationDetails": "إكمال تفاصيل الموقع الناقصة", + "diveSites_backfill_confirm_title": "إكمال تفاصيل الموقع الناقصة؟", + "diveSites_backfill_confirm_body": "{count, plural, =1{موقع غوص واحد له إحداثيات ينقصه البلد أو المنطقة أو البلدة أو المسطح المائي.} other{{count} مواقع غوص لها إحداثيات ينقصها البلد أو المنطقة أو البلدة أو المسطح المائي.}} سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو {minutes} دقائق.", + "diveSites_backfill_confirm_start": "بدء", + "diveSites_backfill_nothingToFill": "كل مواقع الغوص التي لها إحداثيات لديها تفاصيل الموقع بالفعل.", + "diveSites_backfill_progress_title": "جارٍ إكمال تفاصيل الموقع", + "diveSites_backfill_progress_count": "{done} من {total}", + "diveSites_backfill_cancel": "إلغاء", + "diveSites_backfill_summary": "تم تحديث {updated}، بدون تغيير {unchanged}، فشل {failed}", + "diveSites_backfill_offline": "البحث عن الموقع غير متاح. تحقق من الاتصال وحاول مرة أخرى.", "diveLog_edit_flightWindowWarning": "ينتهي هذا الغوص بعد آخر وقت آمن للصعود إلى السطح قبل رحلتك ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 371dbe8ac7..862948ee8a 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "Standardwährung", "settings_units_dialog_defaultCurrency": "Standardwährung", "diveSites_list_menu_select": "Tauchplätze auswählen", + "diveSites_list_menu_fillLocationDetails": "Fehlende Ortsangaben ergänzen", + "diveSites_backfill_confirm_title": "Fehlende Ortsangaben ergänzen?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 Tauchplatz mit Koordinaten hat kein Land, keine Region, keinen Ort oder kein Gewässer.} other{{count} Tauchplätze mit Koordinaten haben kein Land, keine Region, keinen Ort oder kein Gewässer.}} Submersion sucht jeden auf OpenStreetMap und füllt nur die leeren Felder aus. Das dauert etwa {minutes} Minuten.", + "diveSites_backfill_confirm_start": "Starten", + "diveSites_backfill_nothingToFill": "Alle Tauchplätze mit Koordinaten haben bereits ihre Ortsangaben.", + "diveSites_backfill_progress_title": "Ortsangaben werden ergänzt", + "diveSites_backfill_progress_count": "{done} von {total}", + "diveSites_backfill_cancel": "Abbrechen", + "diveSites_backfill_summary": "Aktualisiert {updated}, unverändert {unchanged}, fehlgeschlagen {failed}", + "diveSites_backfill_offline": "Die Ortssuche ist nicht verfügbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "diveLog_edit_flightWindowWarning": "Dieser Tauchgang endet nach der letzten sicheren Auftauchzeit für deinen Flug ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5cc24eb3cd..f74aec2d1c 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4942,6 +4942,49 @@ "diveSites_list_error_retry": "Retry", "diveSites_list_menu_import": "Import", "diveSites_list_menu_select": "Select sites", + "diveSites_list_menu_fillLocationDetails": "Fill in missing location details", + "diveSites_backfill_confirm_title": "Fill in missing location details?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 site with coordinates has an empty country, region, town or body of water.} other{{count} sites with coordinates have an empty country, region, town or body of water.}} Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about {minutes} minutes.", + "@diveSites_backfill_confirm_body": { + "placeholders": { + "count": { + "type": "int" + }, + "minutes": { + "type": "int" + } + } + }, + "diveSites_backfill_confirm_start": "Start", + "diveSites_backfill_nothingToFill": "Every site with coordinates already has its location details.", + "diveSites_backfill_progress_title": "Filling in location details", + "diveSites_backfill_progress_count": "{done} of {total}", + "@diveSites_backfill_progress_count": { + "placeholders": { + "done": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "diveSites_backfill_cancel": "Cancel", + "diveSites_backfill_summary": "Updated {updated}, unchanged {unchanged}, failed {failed}", + "@diveSites_backfill_summary": { + "placeholders": { + "updated": { + "type": "int" + }, + "unchanged": { + "type": "int" + }, + "failed": { + "type": "int" + } + } + }, + "diveSites_backfill_offline": "Location lookup is unavailable. Check your connection and try again.", "diveSites_list_search_backTooltip": "Back", "diveSites_list_search_clearTooltip": "Clear Search", "diveSites_list_search_emptyHint": "Search by site name, country, or region", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 13b5ea03ce..f523280b16 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "Moneda predeterminada", "settings_units_dialog_defaultCurrency": "Moneda predeterminada", "diveSites_list_menu_select": "Seleccionar puntos", + "diveSites_list_menu_fillLocationDetails": "Completar datos de ubicación que faltan", + "diveSites_backfill_confirm_title": "¿Completar los datos de ubicación que faltan?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 punto de buceo con coordenadas no tiene país, región, localidad o masa de agua.} other{{count} puntos de buceo con coordenadas no tienen país, región, localidad o masa de agua.}} Submersion consultará cada uno en OpenStreetMap y rellenará solo los campos vacíos. Tarda unos {minutes} minutos.", + "diveSites_backfill_confirm_start": "Iniciar", + "diveSites_backfill_nothingToFill": "Todos los puntos de buceo con coordenadas ya tienen sus datos de ubicación.", + "diveSites_backfill_progress_title": "Completando datos de ubicación", + "diveSites_backfill_progress_count": "{done} de {total}", + "diveSites_backfill_cancel": "Cancelar", + "diveSites_backfill_summary": "Actualizados {updated}, sin cambios {unchanged}, fallidos {failed}", + "diveSites_backfill_offline": "La consulta de ubicación no está disponible. Comprueba tu conexión e inténtalo de nuevo.", "diveLog_edit_flightWindowWarning": "Esta inmersión termina después de la última hora segura para emerger antes de tu vuelo ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index a4651200d0..bc4d501701 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "Devise par défaut", "settings_units_dialog_defaultCurrency": "Devise par défaut", "diveSites_list_menu_select": "Sélectionner des sites", + "diveSites_list_menu_fillLocationDetails": "Compléter les informations de lieu manquantes", + "diveSites_backfill_confirm_title": "Compléter les informations de lieu manquantes ?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 site avec coordonnées n'a pas de pays, de région, de ville ou de plan d'eau.} other{{count} sites avec coordonnées n'ont pas de pays, de région, de ville ou de plan d'eau.}} Submersion recherchera chacun sur OpenStreetMap et ne remplira que les champs vides. Cela prend environ {minutes} minutes.", + "diveSites_backfill_confirm_start": "Démarrer", + "diveSites_backfill_nothingToFill": "Tous les sites avec coordonnées ont déjà leurs informations de lieu.", + "diveSites_backfill_progress_title": "Complément des informations de lieu", + "diveSites_backfill_progress_count": "{done} sur {total}", + "diveSites_backfill_cancel": "Annuler", + "diveSites_backfill_summary": "Mis à jour {updated}, inchangés {unchanged}, échoués {failed}", + "diveSites_backfill_offline": "La recherche de lieu est indisponible. Vérifiez votre connexion et réessayez.", "diveLog_edit_flightWindowWarning": "Cette plongée se termine après l'heure limite de remontée pour votre vol ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index ed39509f49..6b852d4857 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "מטבע ברירת מחדל", "settings_units_dialog_defaultCurrency": "מטבע ברירת מחדל", "diveSites_list_menu_select": "בחירת אתרים", + "diveSites_list_menu_fillLocationDetails": "השלמת פרטי מיקום חסרים", + "diveSites_backfill_confirm_title": "להשלים פרטי מיקום חסרים?", + "diveSites_backfill_confirm_body": "{count, plural, =1{לאתר אחד עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.} other{ל-{count} אתרים עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.}} Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך כ-{minutes} דקות.", + "diveSites_backfill_confirm_start": "התחלה", + "diveSites_backfill_nothingToFill": "לכל האתרים עם קואורדינטות כבר יש פרטי מיקום.", + "diveSites_backfill_progress_title": "משלים פרטי מיקום", + "diveSites_backfill_progress_count": "{done} מתוך {total}", + "diveSites_backfill_cancel": "ביטול", + "diveSites_backfill_summary": "עודכנו {updated}, ללא שינוי {unchanged}, נכשלו {failed}", + "diveSites_backfill_offline": "חיפוש המיקום אינו זמין. בדקו את החיבור ונסו שוב.", "diveLog_edit_flightWindowWarning": "הצלילה הזו מסתיימת אחרי הזמן הבטוח האחרון לעלייה לפני הטיסה שלך ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index a2671bd57d..af407ff876 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "Alapértelmezett pénznem", "settings_units_dialog_defaultCurrency": "Alapértelmezett pénznem", "diveSites_list_menu_select": "Merülőhelyek kiválasztása", + "diveSites_list_menu_fillLocationDetails": "Hiányzó helyadatok kitöltése", + "diveSites_backfill_confirm_title": "Kitölti a hiányzó helyadatokat?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 koordinátával rendelkező merülőhelynek üres az országa, régiója, települése vagy víztestje.} other{{count} koordinátával rendelkező merülőhelynek üres az országa, régiója, települése vagy víztestje.}} A Submersion mindegyiket lekérdezi az OpenStreetMapról, és csak az üres mezőket tölti ki. Ez körülbelül {minutes} percet vesz igénybe.", + "diveSites_backfill_confirm_start": "Indítás", + "diveSites_backfill_nothingToFill": "Minden koordinátával rendelkező merülőhelynek megvannak a helyadatai.", + "diveSites_backfill_progress_title": "Helyadatok kitöltése", + "diveSites_backfill_progress_count": "{done} / {total}", + "diveSites_backfill_cancel": "Mégse", + "diveSites_backfill_summary": "Frissítve {updated}, változatlan {unchanged}, sikertelen {failed}", + "diveSites_backfill_offline": "A helylekérdezés nem érhető el. Ellenőrizze a kapcsolatot, és próbálja újra.", "diveLog_edit_flightWindowWarning": "Ez a merülés a járatod előtti utolsó biztonságos felszínre érési idő után ér véget ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index a32423deda..5474c7591d 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "Valuta predefinita", "settings_units_dialog_defaultCurrency": "Valuta predefinita", "diveSites_list_menu_select": "Seleziona siti", + "diveSites_list_menu_fillLocationDetails": "Completa i dettagli di località mancanti", + "diveSites_backfill_confirm_title": "Completare i dettagli di località mancanti?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 sito con coordinate non ha paese, regione, città o specchio d'acqua.} other{{count} siti con coordinate non hanno paese, regione, città o specchio d'acqua.}} Submersion cercherà ciascuno su OpenStreetMap e compilerà solo i campi vuoti. Richiede circa {minutes} minuti.", + "diveSites_backfill_confirm_start": "Avvia", + "diveSites_backfill_nothingToFill": "Tutti i siti con coordinate hanno già i dettagli di località.", + "diveSites_backfill_progress_title": "Completamento dei dettagli di località", + "diveSites_backfill_progress_count": "{done} di {total}", + "diveSites_backfill_cancel": "Annulla", + "diveSites_backfill_summary": "Aggiornati {updated}, invariati {unchanged}, falliti {failed}", + "diveSites_backfill_offline": "La ricerca della località non è disponibile. Controlla la connessione e riprova.", "diveLog_edit_flightWindowWarning": "Questa immersione termina dopo l'ultimo orario sicuro di riemersione per il tuo volo ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 6163a7f84c..5cd730eb0b 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -14789,6 +14789,66 @@ abstract class AppLocalizations { /// **'Select sites'** String get diveSites_list_menu_select; + /// No description provided for @diveSites_list_menu_fillLocationDetails. + /// + /// In en, this message translates to: + /// **'Fill in missing location details'** + String get diveSites_list_menu_fillLocationDetails; + + /// No description provided for @diveSites_backfill_confirm_title. + /// + /// In en, this message translates to: + /// **'Fill in missing location details?'** + String get diveSites_backfill_confirm_title; + + /// No description provided for @diveSites_backfill_confirm_body. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 site with coordinates has an empty country, region, town or body of water.} other{{count} sites with coordinates have an empty country, region, town or body of water.}} Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about {minutes} minutes.'** + String diveSites_backfill_confirm_body(int count, int minutes); + + /// No description provided for @diveSites_backfill_confirm_start. + /// + /// In en, this message translates to: + /// **'Start'** + String get diveSites_backfill_confirm_start; + + /// No description provided for @diveSites_backfill_nothingToFill. + /// + /// In en, this message translates to: + /// **'Every site with coordinates already has its location details.'** + String get diveSites_backfill_nothingToFill; + + /// No description provided for @diveSites_backfill_progress_title. + /// + /// In en, this message translates to: + /// **'Filling in location details'** + String get diveSites_backfill_progress_title; + + /// No description provided for @diveSites_backfill_progress_count. + /// + /// In en, this message translates to: + /// **'{done} of {total}'** + String diveSites_backfill_progress_count(int done, int total); + + /// No description provided for @diveSites_backfill_cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get diveSites_backfill_cancel; + + /// No description provided for @diveSites_backfill_summary. + /// + /// In en, this message translates to: + /// **'Updated {updated}, unchanged {unchanged}, failed {failed}'** + String diveSites_backfill_summary(int updated, int unchanged, int failed); + + /// No description provided for @diveSites_backfill_offline. + /// + /// In en, this message translates to: + /// **'Location lookup is unavailable. Check your connection and try again.'** + String get diveSites_backfill_offline; + /// No description provided for @diveSites_list_search_backTooltip. /// /// 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 028002d35c..d6b1352619 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8620,6 +8620,53 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveSites_list_menu_select => 'تحديد المواقع'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'إكمال تفاصيل الموقع الناقصة'; + + @override + String get diveSites_backfill_confirm_title => 'إكمال تفاصيل الموقع الناقصة؟'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count مواقع غوص لها إحداثيات ينقصها البلد أو المنطقة أو البلدة أو المسطح المائي.', + one: + 'موقع غوص واحد له إحداثيات ينقصه البلد أو المنطقة أو البلدة أو المسطح المائي.', + ); + return '$_temp0 سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو $minutes دقائق.'; + } + + @override + String get diveSites_backfill_confirm_start => 'بدء'; + + @override + String get diveSites_backfill_nothingToFill => + 'كل مواقع الغوص التي لها إحداثيات لديها تفاصيل الموقع بالفعل.'; + + @override + String get diveSites_backfill_progress_title => 'جارٍ إكمال تفاصيل الموقع'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done من $total'; + } + + @override + String get diveSites_backfill_cancel => 'إلغاء'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'تم تحديث $updated، بدون تغيير $unchanged، فشل $failed'; + } + + @override + String get diveSites_backfill_offline => + 'البحث عن الموقع غير متاح. تحقق من الاتصال وحاول مرة أخرى.'; + @override String get diveSites_list_search_backTooltip => 'رجوع'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index f4eef57bce..2cdfbc1f00 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8780,6 +8780,54 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveSites_list_menu_select => 'Tauchplätze auswählen'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Fehlende Ortsangaben ergänzen'; + + @override + String get diveSites_backfill_confirm_title => + 'Fehlende Ortsangaben ergänzen?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count Tauchplätze mit Koordinaten haben kein Land, keine Region, keinen Ort oder kein Gewässer.', + one: + '1 Tauchplatz mit Koordinaten hat kein Land, keine Region, keinen Ort oder kein Gewässer.', + ); + return '$_temp0 Submersion sucht jeden auf OpenStreetMap und füllt nur die leeren Felder aus. Das dauert etwa $minutes Minuten.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Starten'; + + @override + String get diveSites_backfill_nothingToFill => + 'Alle Tauchplätze mit Koordinaten haben bereits ihre Ortsangaben.'; + + @override + String get diveSites_backfill_progress_title => 'Ortsangaben werden ergänzt'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done von $total'; + } + + @override + String get diveSites_backfill_cancel => 'Abbrechen'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Aktualisiert $updated, unverändert $unchanged, fehlgeschlagen $failed'; + } + + @override + String get diveSites_backfill_offline => + 'Die Ortssuche ist nicht verfügbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.'; + @override String get diveSites_list_search_backTooltip => 'Zurück'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index c28eaa0b89..9cadb1dae9 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8636,6 +8636,54 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveSites_list_menu_select => 'Select sites'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Fill in missing location details'; + + @override + String get diveSites_backfill_confirm_title => + 'Fill in missing location details?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count sites with coordinates have an empty country, region, town or body of water.', + one: + '1 site with coordinates has an empty country, region, town or body of water.', + ); + return '$_temp0 Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about $minutes minutes.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Start'; + + @override + String get diveSites_backfill_nothingToFill => + 'Every site with coordinates already has its location details.'; + + @override + String get diveSites_backfill_progress_title => 'Filling in location details'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done of $total'; + } + + @override + String get diveSites_backfill_cancel => 'Cancel'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Updated $updated, unchanged $unchanged, failed $failed'; + } + + @override + String get diveSites_backfill_offline => + 'Location lookup is unavailable. Check your connection and try again.'; + @override String get diveSites_list_search_backTooltip => 'Back'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index e5aad8d308..4eb487e462 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8788,6 +8788,55 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveSites_list_menu_select => 'Seleccionar puntos'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Completar datos de ubicación que faltan'; + + @override + String get diveSites_backfill_confirm_title => + '¿Completar los datos de ubicación que faltan?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count puntos de buceo con coordenadas no tienen país, región, localidad o masa de agua.', + one: + '1 punto de buceo con coordenadas no tiene país, región, localidad o masa de agua.', + ); + return '$_temp0 Submersion consultará cada uno en OpenStreetMap y rellenará solo los campos vacíos. Tarda unos $minutes minutos.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Iniciar'; + + @override + String get diveSites_backfill_nothingToFill => + 'Todos los puntos de buceo con coordenadas ya tienen sus datos de ubicación.'; + + @override + String get diveSites_backfill_progress_title => + 'Completando datos de ubicación'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done de $total'; + } + + @override + String get diveSites_backfill_cancel => 'Cancelar'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Actualizados $updated, sin cambios $unchanged, fallidos $failed'; + } + + @override + String get diveSites_backfill_offline => + 'La consulta de ubicación no está disponible. Comprueba tu conexión e inténtalo de nuevo.'; + @override String get diveSites_list_search_backTooltip => 'Atras'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 2b4b08db62..57500177d2 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8820,6 +8820,55 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveSites_list_menu_select => 'Sélectionner des sites'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Compléter les informations de lieu manquantes'; + + @override + String get diveSites_backfill_confirm_title => + 'Compléter les informations de lieu manquantes ?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count sites avec coordonnées n\'ont pas de pays, de région, de ville ou de plan d\'eau.', + one: + '1 site avec coordonnées n\'a pas de pays, de région, de ville ou de plan d\'eau.', + ); + return '$_temp0 Submersion recherchera chacun sur OpenStreetMap et ne remplira que les champs vides. Cela prend environ $minutes minutes.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Démarrer'; + + @override + String get diveSites_backfill_nothingToFill => + 'Tous les sites avec coordonnées ont déjà leurs informations de lieu.'; + + @override + String get diveSites_backfill_progress_title => + 'Complément des informations de lieu'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done sur $total'; + } + + @override + String get diveSites_backfill_cancel => 'Annuler'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Mis à jour $updated, inchangés $unchanged, échoués $failed'; + } + + @override + String get diveSites_backfill_offline => + 'La recherche de lieu est indisponible. Vérifiez votre connexion et réessayez.'; + @override String get diveSites_list_search_backTooltip => 'Retour'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 2331787dcb..72a478444a 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8570,6 +8570,51 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveSites_list_menu_select => 'בחירת אתרים'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'השלמת פרטי מיקום חסרים'; + + @override + String get diveSites_backfill_confirm_title => 'להשלים פרטי מיקום חסרים?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'ל-$count אתרים עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.', + one: 'לאתר אחד עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.', + ); + return '$_temp0 Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך כ-$minutes דקות.'; + } + + @override + String get diveSites_backfill_confirm_start => 'התחלה'; + + @override + String get diveSites_backfill_nothingToFill => + 'לכל האתרים עם קואורדינטות כבר יש פרטי מיקום.'; + + @override + String get diveSites_backfill_progress_title => 'משלים פרטי מיקום'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done מתוך $total'; + } + + @override + String get diveSites_backfill_cancel => 'ביטול'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'עודכנו $updated, ללא שינוי $unchanged, נכשלו $failed'; + } + + @override + String get diveSites_backfill_offline => + 'חיפוש המיקום אינו זמין. בדקו את החיבור ונסו שוב.'; + @override String get diveSites_list_search_backTooltip => 'חזרה'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 8c5aa1807f..bcc84a6820 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8766,6 +8766,54 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveSites_list_menu_select => 'Merülőhelyek kiválasztása'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Hiányzó helyadatok kitöltése'; + + @override + String get diveSites_backfill_confirm_title => + 'Kitölti a hiányzó helyadatokat?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count koordinátával rendelkező merülőhelynek üres az országa, régiója, települése vagy víztestje.', + one: + '1 koordinátával rendelkező merülőhelynek üres az országa, régiója, települése vagy víztestje.', + ); + return '$_temp0 A Submersion mindegyiket lekérdezi az OpenStreetMapról, és csak az üres mezőket tölti ki. Ez körülbelül $minutes percet vesz igénybe.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Indítás'; + + @override + String get diveSites_backfill_nothingToFill => + 'Minden koordinátával rendelkező merülőhelynek megvannak a helyadatai.'; + + @override + String get diveSites_backfill_progress_title => 'Helyadatok kitöltése'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done / $total'; + } + + @override + String get diveSites_backfill_cancel => 'Mégse'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Frissítve $updated, változatlan $unchanged, sikertelen $failed'; + } + + @override + String get diveSites_backfill_offline => + 'A helylekérdezés nem érhető el. Ellenőrizze a kapcsolatot, és próbálja újra.'; + @override String get diveSites_list_search_backTooltip => 'Vissza'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 05d7a99406..1c1a905036 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8787,6 +8787,55 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveSites_list_menu_select => 'Seleziona siti'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Completa i dettagli di località mancanti'; + + @override + String get diveSites_backfill_confirm_title => + 'Completare i dettagli di località mancanti?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count siti con coordinate non hanno paese, regione, città o specchio d\'acqua.', + one: + '1 sito con coordinate non ha paese, regione, città o specchio d\'acqua.', + ); + return '$_temp0 Submersion cercherà ciascuno su OpenStreetMap e compilerà solo i campi vuoti. Richiede circa $minutes minuti.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Avvia'; + + @override + String get diveSites_backfill_nothingToFill => + 'Tutti i siti con coordinate hanno già i dettagli di località.'; + + @override + String get diveSites_backfill_progress_title => + 'Completamento dei dettagli di località'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done di $total'; + } + + @override + String get diveSites_backfill_cancel => 'Annulla'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Aggiornati $updated, invariati $unchanged, falliti $failed'; + } + + @override + String get diveSites_backfill_offline => + 'La ricerca della località non è disponibile. Controlla la connessione e riprova.'; + @override String get diveSites_list_search_backTooltip => 'Indietro'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 57b010eeba..17c4fd3beb 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8717,6 +8717,54 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveSites_list_menu_select => 'Duikstekken selecteren'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Ontbrekende locatiegegevens aanvullen'; + + @override + String get diveSites_backfill_confirm_title => + 'Ontbrekende locatiegegevens aanvullen?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count duikstekken met coördinaten hebben geen land, regio, plaats of water.', + one: + '1 duikstek met coördinaten heeft geen land, regio, plaats of water.', + ); + return '$_temp0 Submersion zoekt elke stek op via OpenStreetMap en vult alleen lege velden in. Dit duurt ongeveer $minutes minuten.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Starten'; + + @override + String get diveSites_backfill_nothingToFill => + 'Elke duikstek met coördinaten heeft al locatiegegevens.'; + + @override + String get diveSites_backfill_progress_title => 'Locatiegegevens aanvullen'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done van $total'; + } + + @override + String get diveSites_backfill_cancel => 'Annuleren'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Bijgewerkt $updated, ongewijzigd $unchanged, mislukt $failed'; + } + + @override + String get diveSites_backfill_offline => + 'Locatie opzoeken is niet beschikbaar. Controleer je verbinding en probeer het opnieuw.'; + @override String get diveSites_list_search_backTooltip => 'Terug'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index d02053d116..efdd3b6c16 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8790,6 +8790,55 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveSites_list_menu_select => 'Selecionar pontos'; + @override + String get diveSites_list_menu_fillLocationDetails => + 'Preencher detalhes de localização em falta'; + + @override + String get diveSites_backfill_confirm_title => + 'Preencher os detalhes de localização em falta?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count locais com coordenadas não têm país, região, cidade ou corpo de água.', + one: + '1 local com coordenadas não tem país, região, cidade ou corpo de água.', + ); + return '$_temp0 O Submersion consultará cada um no OpenStreetMap e preencherá apenas os campos vazios. Demora cerca de $minutes minutos.'; + } + + @override + String get diveSites_backfill_confirm_start => 'Iniciar'; + + @override + String get diveSites_backfill_nothingToFill => + 'Todos os locais com coordenadas já têm os seus detalhes de localização.'; + + @override + String get diveSites_backfill_progress_title => + 'A preencher detalhes de localização'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done de $total'; + } + + @override + String get diveSites_backfill_cancel => 'Cancelar'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return 'Atualizados $updated, inalterados $unchanged, falhados $failed'; + } + + @override + String get diveSites_backfill_offline => + 'A consulta de localização não está disponível. Verifique a sua ligação e tente novamente.'; + @override String get diveSites_list_search_backTooltip => 'Voltar'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index a1d3161964..63d8a95053 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8369,6 +8369,48 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveSites_list_menu_select => '选择潜水点'; + @override + String get diveSites_list_menu_fillLocationDetails => '补全缺失的地点信息'; + + @override + String get diveSites_backfill_confirm_title => '补全缺失的地点信息?'; + + @override + String diveSites_backfill_confirm_body(int count, int minutes) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 个有坐标的潜点缺少国家、地区、城镇或水域。', + one: '1 个有坐标的潜点缺少国家、地区、城镇或水域。', + ); + return '$_temp0 Submersion 将在 OpenStreetMap 上逐个查找,并仅填写空白字段。大约需要 $minutes 分钟。'; + } + + @override + String get diveSites_backfill_confirm_start => '开始'; + + @override + String get diveSites_backfill_nothingToFill => '所有有坐标的潜点都已有地点信息。'; + + @override + String get diveSites_backfill_progress_title => '正在补全地点信息'; + + @override + String diveSites_backfill_progress_count(int done, int total) { + return '$done / $total'; + } + + @override + String get diveSites_backfill_cancel => '取消'; + + @override + String diveSites_backfill_summary(int updated, int unchanged, int failed) { + return '已更新 $updated,未变 $unchanged,失败 $failed'; + } + + @override + String get diveSites_backfill_offline => '地点查找不可用。请检查网络连接后重试。'; + @override String get diveSites_list_search_backTooltip => '返回'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 2cd6c9154e..aede831503 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "Standaardvaluta", "settings_units_dialog_defaultCurrency": "Standaardvaluta", "diveSites_list_menu_select": "Duikstekken selecteren", + "diveSites_list_menu_fillLocationDetails": "Ontbrekende locatiegegevens aanvullen", + "diveSites_backfill_confirm_title": "Ontbrekende locatiegegevens aanvullen?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 duikstek met coördinaten heeft geen land, regio, plaats of water.} other{{count} duikstekken met coördinaten hebben geen land, regio, plaats of water.}} Submersion zoekt elke stek op via OpenStreetMap en vult alleen lege velden in. Dit duurt ongeveer {minutes} minuten.", + "diveSites_backfill_confirm_start": "Starten", + "diveSites_backfill_nothingToFill": "Elke duikstek met coördinaten heeft al locatiegegevens.", + "diveSites_backfill_progress_title": "Locatiegegevens aanvullen", + "diveSites_backfill_progress_count": "{done} van {total}", + "diveSites_backfill_cancel": "Annuleren", + "diveSites_backfill_summary": "Bijgewerkt {updated}, ongewijzigd {unchanged}, mislukt {failed}", + "diveSites_backfill_offline": "Locatie opzoeken is niet beschikbaar. Controleer je verbinding en probeer het opnieuw.", "diveLog_edit_flightWindowWarning": "Deze duik eindigt na het laatste veilige opstijgmoment voor je vlucht ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index feebdcd4d8..e4fd1bc0ca 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "Moeda padrão", "settings_units_dialog_defaultCurrency": "Moeda padrão", "diveSites_list_menu_select": "Selecionar pontos", + "diveSites_list_menu_fillLocationDetails": "Preencher detalhes de localização em falta", + "diveSites_backfill_confirm_title": "Preencher os detalhes de localização em falta?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 local com coordenadas não tem país, região, cidade ou corpo de água.} other{{count} locais com coordenadas não têm país, região, cidade ou corpo de água.}} O Submersion consultará cada um no OpenStreetMap e preencherá apenas os campos vazios. Demora cerca de {minutes} minutos.", + "diveSites_backfill_confirm_start": "Iniciar", + "diveSites_backfill_nothingToFill": "Todos os locais com coordenadas já têm os seus detalhes de localização.", + "diveSites_backfill_progress_title": "A preencher detalhes de localização", + "diveSites_backfill_progress_count": "{done} de {total}", + "diveSites_backfill_cancel": "Cancelar", + "diveSites_backfill_summary": "Atualizados {updated}, inalterados {unchanged}, falhados {failed}", + "diveSites_backfill_offline": "A consulta de localização não está disponível. Verifique a sua ligação e tente novamente.", "diveLog_edit_flightWindowWarning": "Este mergulho termina depois do último horário seguro para emergir antes do seu voo ({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b659c3a887..22b746122b 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -9,6 +9,16 @@ "settings_units_defaultCurrency": "默认货币", "settings_units_dialog_defaultCurrency": "默认货币", "diveSites_list_menu_select": "选择潜水点", + "diveSites_list_menu_fillLocationDetails": "补全缺失的地点信息", + "diveSites_backfill_confirm_title": "补全缺失的地点信息?", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 个有坐标的潜点缺少国家、地区、城镇或水域。} other{{count} 个有坐标的潜点缺少国家、地区、城镇或水域。}} Submersion 将在 OpenStreetMap 上逐个查找,并仅填写空白字段。大约需要 {minutes} 分钟。", + "diveSites_backfill_confirm_start": "开始", + "diveSites_backfill_nothingToFill": "所有有坐标的潜点都已有地点信息。", + "diveSites_backfill_progress_title": "正在补全地点信息", + "diveSites_backfill_progress_count": "{done} / {total}", + "diveSites_backfill_cancel": "取消", + "diveSites_backfill_summary": "已更新 {updated},未变 {unchanged},失败 {failed}", + "diveSites_backfill_offline": "地点查找不可用。请检查网络连接后重试。", "diveLog_edit_flightWindowWarning": "此次潜水的结束时间晚于您航班的最后安全出水时间({time})", "@diveLog_edit_flightWindowWarning": { "placeholders": { diff --git a/test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart b/test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart new file mode 100644 index 0000000000..4f0d3c9fd0 --- /dev/null +++ b/test/features/dive_sites/presentation/providers/site_location_backfill_provider_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/location_service_provider.dart'; +import 'package:submersion/core/services/geocoding/place_lookup.dart'; +import 'package:submersion/core/services/location_service.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_location_backfill_provider.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Blocks each lookup until [release] is called, so a test can observe the +/// running state and cancel mid-run. +class _GatedLocationService implements LocationService { + final List> gates = []; + final List languages = []; + + void release() => gates.removeAt(0).complete(); + + @override + Future reverseGeocode( + double latitude, + double longitude, { + required String languageCode, + }) async { + languages.add(languageCode); + final gate = Completer(); + gates.add(gate); + await gate.future; + return const PlaceLookup(country: 'Switzerland', locality: 'Weggis'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Settings pinned to German place names, without a database round-trip. +class _GermanSettings extends StateNotifier + implements SettingsNotifier { + _GermanSettings() : super(const AppSettings(placeNameLanguage: 'de')); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late ProviderContainer container; + late SiteRepository sites; + late _GatedLocationService location; + + Future tick() => Future.delayed(Duration.zero); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + sites = SiteRepository(); + location = _GatedLocationService(); + container = ProviderContainer( + overrides: [ + siteRepositoryProvider.overrideWithValue(sites), + sharedPreferencesProvider.overrideWithValue(prefs), + validatedCurrentDiverIdProvider.overrideWith((ref) async => null), + locationServiceProvider.overrideWithValue(location), + settingsProvider.overrideWith((_) => _GermanSettings()), + ], + ); + await sites.createSite( + const DiveSite(id: 'a', name: 'A', location: GeoPoint(47.0, 8.4)), + ); + await sites.createSite( + const DiveSite(id: 'b', name: 'B', location: GeoPoint(47.1, 8.5)), + ); + }); + + tearDown(() async { + container.dispose(); + await tearDownTestDatabase(); + }); + + Future runToCompletion(Future run) async { + while (location.gates.isEmpty) { + await tick(); + } + location.release(); + await tick(); + while (location.gates.isEmpty) { + await tick(); + } + location.release(); + await run; + } + + test('starts idle and counts candidates', () async { + expect(container.read(siteLocationBackfillProvider), isA()); + final notifier = container.read(siteLocationBackfillProvider.notifier); + expect(await notifier.countCandidates(), 2); + }); + + test('reports progress while running and finishes with a summary', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + final run = notifier.start(); + while (location.gates.isEmpty) { + await tick(); + } + + expect( + container.read(siteLocationBackfillProvider), + isA() + .having((s) => s.done, 'done', 0) + .having((s) => s.total, 'total', 2), + ); + + location.release(); + while (location.gates.isEmpty) { + await tick(); + } + expect( + container.read(siteLocationBackfillProvider), + isA().having((s) => s.done, 'done', 1), + ); + location.release(); + await run; + + final state = container.read(siteLocationBackfillProvider); + expect(state, isA()); + expect((state as BackfillFinished).summary.updated, 2); + expect((await sites.getSiteById('a'))!.city, 'Weggis'); + }); + + test('a second start while running is a no-op', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + final first = notifier.start(); + while (location.gates.isEmpty) { + await tick(); + } + await notifier.start(); + expect(location.gates, hasLength(1), reason: 'no second run began'); + + location.release(); + while (location.gates.isEmpty) { + await tick(); + } + location.release(); + await first; + }); + + test('cancel stops after the current site', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + final run = notifier.start(); + while (location.gates.isEmpty) { + await tick(); + } + + notifier.cancel(); + location.release(); + await run; + + final state = container.read(siteLocationBackfillProvider); + expect((state as BackfillFinished).summary.cancelled, isTrue); + expect(location.gates, isEmpty); + }); + + test('reset returns to idle', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + await runToCompletion(notifier.start()); + + notifier.reset(); + expect(container.read(siteLocationBackfillProvider), isA()); + }); + + test('looks up in the place name language', () async { + final notifier = container.read(siteLocationBackfillProvider.notifier); + await runToCompletion(notifier.start()); + expect(location.languages, ['de', 'de']); + }); +} diff --git a/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart b/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart new file mode 100644 index 0000000000..ec000ca00d --- /dev/null +++ b/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_sites/domain/services/site_location_backfill_service.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_location_backfill_provider.dart'; +import 'package:submersion/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// A scripted notifier so the dialog can be driven without a database or +/// network: [candidates] answers the count, [start] walks [script]. +class _ScriptedBackfill extends StateNotifier + implements SiteLocationBackfillNotifier { + _ScriptedBackfill({ + required this.candidates, + required this.script, + this.stepDelay = const Duration(milliseconds: 10), + }) : super(const BackfillIdle()); + + final int candidates; + final List script; + final Duration stepDelay; + int startCalls = 0; + bool cancelled = false; + + @override + Future countCandidates() async => candidates; + + @override + Future start() async { + startCalls++; + for (final s in script) { + await Future.delayed(stepDelay); + state = s; + } + } + + @override + void cancel() => cancelled = true; + + @override + void reset() => state = const BackfillIdle(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + Widget host(_ScriptedBackfill notifier) => ProviderScope( + overrides: [siteLocationBackfillProvider.overrideWith((_) => notifier)], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => showSiteLocationBackfillFlow(context, ref), + child: const Text('go'), + ), + ), + ), + ), + ); + + testWidgets('says so when there is nothing to fill', (tester) async { + final notifier = _ScriptedBackfill(candidates: 0, script: const []); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Every site with coordinates already has its location details.', + ), + findsOneWidget, + ); + expect(notifier.startCalls, 0); + }); + + testWidgets('confirms with the count and estimate, then shows progress and ' + 'a summary', (tester) async { + final notifier = _ScriptedBackfill( + candidates: 104, + script: const [ + BackfillRunning(done: 0, total: 104), + BackfillRunning(done: 12, total: 104), + BackfillFinished( + BackfillSummary(total: 104, updated: 90, unchanged: 13, failed: 1), + ), + ], + ); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect(find.text('Fill in missing location details?'), findsOneWidget); + expect(find.textContaining('104 sites with coordinates'), findsOneWidget); + expect(find.textContaining('about 4 minutes'), findsOneWidget); + + await tester.tap(find.text('Start')); + await tester.pump(const Duration(milliseconds: 15)); + expect(find.text('Filling in location details'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 10)); + expect(find.text('12 of 104'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('Filling in location details'), findsNothing); + expect(find.text('Updated 90, unchanged 13, failed 1'), findsOneWidget); + }); + + testWidgets('cancel asks the notifier to stop', (tester) async { + // Slow steps so the progress dialog has finished animating in before + // the test taps its Cancel button. + final notifier = _ScriptedBackfill( + candidates: 3, + stepDelay: const Duration(milliseconds: 500), + script: const [ + BackfillRunning(done: 0, total: 3), + BackfillFinished( + BackfillSummary( + total: 3, + updated: 1, + unchanged: 0, + failed: 0, + cancelled: true, + ), + ), + ], + ); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start')); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); + expect(find.text('0 of 3'), findsOneWidget); + + // The confirm dialog's Cancel may still be mid-exit; target the progress + // dialog's own button. + final progressDialog = find.ancestor( + of: find.text('Filling in location details'), + matching: find.byType(AlertDialog), + ); + await tester.tap( + find.descendant(of: progressDialog, matching: find.text('Cancel')), + ); + await tester.pumpAndSettle(); + + expect(notifier.cancelled, isTrue); + }); + + testWidgets('an offline run shows the offline message', (tester) async { + final notifier = _ScriptedBackfill( + candidates: 3, + script: const [ + BackfillRunning(done: 0, total: 3), + BackfillFinished( + BackfillSummary( + total: 3, + updated: 0, + unchanged: 0, + failed: 0, + offline: true, + ), + ), + ], + ); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Location lookup is unavailable. Check your connection and try again.', + ), + findsOneWidget, + ); + }); +} From 590fc529b105fd4249277b9512357cc9fd2f431d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:59:40 -0400 Subject: [PATCH 071/122] i18n: add the SAC cylinder-volume hint to every locale (#386) --- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 4 +++- lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 ++++++ lib/l10n/arb/app_localizations_ar.dart | 5 +++++ lib/l10n/arb/app_localizations_de.dart | 5 +++++ lib/l10n/arb/app_localizations_en.dart | 5 +++++ lib/l10n/arb/app_localizations_es.dart | 5 +++++ lib/l10n/arb/app_localizations_fr.dart | 5 +++++ lib/l10n/arb/app_localizations_he.dart | 5 +++++ lib/l10n/arb/app_localizations_hu.dart | 5 +++++ lib/l10n/arb/app_localizations_it.dart | 5 +++++ lib/l10n/arb/app_localizations_nl.dart | 5 +++++ lib/l10n/arb/app_localizations_pt.dart | 5 +++++ lib/l10n/arb/app_localizations_zh.dart | 5 +++++ lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + 23 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..0c9c84ba5f 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "موقع الغوص على ارتفاع", "diveLog_detail_altitudeMismatch_subtitle": "هذا الموقع مسجل له ارتفاع لكن الغطسة بلا ارتفاع، لذا افترض تحليل تخفيف الضغط مستوى سطح البحر. عيّن ارتفاع الغطسة للتصحيح.", + "diveLog_detail_sacVolumeHint": "أضف حجم الأسطوانة لعرض معدل SAC بوحدة {unit}/min", "emergencyCard_title": "الطوارئ", "emergencyCard_callDan": "اتصل بـ {name}", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..d7a4e6a317 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "Tauchplatz liegt in Höhenlage", "diveLog_detail_altitudeMismatch_subtitle": "Für diesen Platz ist eine Höhe hinterlegt, der Tauchgang hat jedoch keine, daher ging die Deko-Analyse von Meereshöhe aus. Höhe des Tauchgangs setzen, um dies zu korrigieren.", + "diveLog_detail_sacVolumeHint": "Flaschenvolumen eintragen, um den AMV in {unit}/min anzuzeigen", "emergencyCard_title": "Notfall", "emergencyCard_callDan": "{name} anrufen", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..14fa2b9519 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -15413,6 +15413,7 @@ "safetyHub_noFly_disclaimer": "DAN/UHMS guideline intervals from your last dive. Not a substitute for your dive computer's no-fly time.", "diveLog_detail_altitudeMismatch_title": "Site is at altitude", "diveLog_detail_altitudeMismatch_subtitle": "This site records an altitude but the dive has none set, so decompression analysis assumed sea level. Set the dive's altitude to correct it.", + "diveLog_detail_sacVolumeHint": "Add a cylinder volume to show SAC in {unit}/min", "safetyHub_alert_noFly": "No-fly: {remaining} remaining", "@safetyHub_alert_noFly": { "placeholders": { @@ -18640,5 +18641,6 @@ "@settings_dataSources_appleHealth_dataTypeDepth": {"description": "HealthKit data type disclosure: underwater depth."}, "@settings_dataSources_appleHealth_dataTypeWaterTemp": {"description": "HealthKit data type disclosure: water temperature."}, "@settings_dataSources_appleHealth_permissionManagedInHealth": {"description": "Permission row shown when the platform will not disclose read access."}, - "@settings_dataSources_appleHealth_permissionUnsupported": {"description": "Permission row shown when HealthKit is unavailable on this device."} + "@settings_dataSources_appleHealth_permissionUnsupported": {"description": "Permission row shown when HealthKit is unavailable on this device."}, + "@diveLog_detail_sacVolumeHint": {"placeholders": {"unit": {"type": "String"}}} } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..192071010e 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "El punto de buceo está en altitud", "diveLog_detail_altitudeMismatch_subtitle": "Este punto registra una altitud pero la inmersión no tiene ninguna, así que el análisis de descompresión asumió nivel del mar. Establece la altitud de la inmersión para corregirlo.", + "diveLog_detail_sacVolumeHint": "Añade el volumen del cilindro para mostrar el SAC en {unit}/min", "emergencyCard_title": "Emergencia", "emergencyCard_callDan": "Llamar a {name}", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..1b54bf33e1 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "Le site est en altitude", "diveLog_detail_altitudeMismatch_subtitle": "Ce site indique une altitude mais la plongée n'en a aucune : l'analyse de décompression a supposé le niveau de la mer. Définissez l'altitude de la plongée pour corriger.", + "diveLog_detail_sacVolumeHint": "Ajoutez le volume du bloc pour afficher la consommation SAC en {unit}/min", "emergencyCard_title": "Urgence", "emergencyCard_callDan": "Appeler {name}", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..bfda42feda 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "אתר הצלילה נמצא בגובה", "diveLog_detail_altitudeMismatch_subtitle": "לאתר זה רשום גובה אך לצלילה אין, ולכן ניתוח הדקומפרסיה הניח גובה פני הים. הגדר את גובה הצלילה כדי לתקן.", + "diveLog_detail_sacVolumeHint": "הוסף נפח בלון כדי להציג קצב SAC ב-{unit}/min", "emergencyCard_title": "חירום", "emergencyCard_callDan": "התקשר אל {name}", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..9c9bd3cd71 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "A merülőhely magaslaton fekszik", "diveLog_detail_altitudeMismatch_subtitle": "Ehhez a helyhez magasság van rögzítve, a merüléshez azonban nincs, így a dekompressziós elemzés tengerszintet feltételezett. A javításhoz állítsd be a merülés magasságát.", + "diveLog_detail_sacVolumeHint": "Add meg a palack térfogatát, hogy a SAC érték {unit}/min-ben jelenjen meg", "emergencyCard_title": "Vészhelyzet", "emergencyCard_callDan": "{name} hívása", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..bf22096ce3 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "Il sito è in quota", "diveLog_detail_altitudeMismatch_subtitle": "Questo sito registra un'altitudine ma l'immersione non ne ha una, quindi l'analisi di decompressione ha assunto il livello del mare. Imposta l'altitudine dell'immersione per correggere.", + "diveLog_detail_sacVolumeHint": "Aggiungi il volume della bombola per mostrare il consumo SAC in {unit}/min", "emergencyCard_title": "Emergenza", "emergencyCard_callDan": "Chiama {name}", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..aed846ea8d 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -40076,6 +40076,12 @@ abstract class AppLocalizations { /// **'This site records an altitude but the dive has none set, so decompression analysis assumed sea level. Set the dive\'s altitude to correct it.'** String get diveLog_detail_altitudeMismatch_subtitle; + /// No description provided for @diveLog_detail_sacVolumeHint. + /// + /// In en, this message translates to: + /// **'Add a cylinder volume to show SAC in {unit}/min'** + String diveLog_detail_sacVolumeHint(String unit); + /// No description provided for @safetyHub_alert_noFly. /// /// 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..77ef4b6914 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -23602,6 +23602,11 @@ class AppLocalizationsAr extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'هذا الموقع مسجل له ارتفاع لكن الغطسة بلا ارتفاع، لذا افترض تحليل تخفيف الضغط مستوى سطح البحر. عيّن ارتفاع الغطسة للتصحيح.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'أضف حجم الأسطوانة لعرض معدل SAC بوحدة $unit/min'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'حظر الطيران: متبقٍ $remaining'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..fb7905ae8e 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -23984,6 +23984,11 @@ class AppLocalizationsDe extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'Für diesen Platz ist eine Höhe hinterlegt, der Tauchgang hat jedoch keine, daher ging die Deko-Analyse von Meereshöhe aus. Höhe des Tauchgangs setzen, um dies zu korrigieren.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Flaschenvolumen eintragen, um den AMV in $unit/min anzuzeigen'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'Flugverbot: noch $remaining'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..88ab9838b0 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -23625,6 +23625,11 @@ class AppLocalizationsEn extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'This site records an altitude but the dive has none set, so decompression analysis assumed sea level. Set the dive\'s altitude to correct it.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Add a cylinder volume to show SAC in $unit/min'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'No-fly: $remaining remaining'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..5f083a6dbd 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -24042,6 +24042,11 @@ class AppLocalizationsEs extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'Este punto registra una altitud pero la inmersión no tiene ninguna, así que el análisis de descompresión asumió nivel del mar. Establece la altitud de la inmersión para corregirlo.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Añade el volumen del cilindro para mostrar el SAC en $unit/min'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'No volar: quedan $remaining'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..2f8c4b3259 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -24099,6 +24099,11 @@ class AppLocalizationsFr extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'Ce site indique une altitude mais la plongée n\'en a aucune : l\'analyse de décompression a supposé le niveau de la mer. Définissez l\'altitude de la plongée pour corriger.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Ajoutez le volume du bloc pour afficher la consommation SAC en $unit/min'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'Interdiction de vol : $remaining restant'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..f94dee9fe3 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -23434,6 +23434,11 @@ class AppLocalizationsHe extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'לאתר זה רשום גובה אך לצלילה אין, ולכן ניתוח הדקומפרסיה הניח גובה פני הים. הגדר את גובה הצלילה כדי לתקן.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'הוסף נפח בלון כדי להציג קצב SAC ב-$unit/min'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'איסור טיסה: נותרו $remaining'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..3c62da155c 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -23943,6 +23943,11 @@ class AppLocalizationsHu extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'Ehhez a helyhez magasság van rögzítve, a merüléshez azonban nincs, így a dekompressziós elemzés tengerszintet feltételezett. A javításhoz állítsd be a merülés magasságát.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Add meg a palack térfogatát, hogy a SAC érték $unit/min-ben jelenjen meg'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'Repülési tilalom: $remaining van hátra'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..e95f485817 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -24025,6 +24025,11 @@ class AppLocalizationsIt extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'Questo sito registra un\'altitudine ma l\'immersione non ne ha una, quindi l\'analisi di decompressione ha assunto il livello del mare. Imposta l\'altitudine dell\'immersione per correggere.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Aggiungi il volume della bombola per mostrare il consumo SAC in $unit/min'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'No-fly: mancano $remaining'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..907d1cf1c9 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -23847,6 +23847,11 @@ class AppLocalizationsNl extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'Deze stek heeft een hoogte geregistreerd maar de duik niet, dus de deco-analyse ging uit van zeeniveau. Stel de hoogte van de duik in om dit te corrigeren.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Voeg een flesvolume toe om het SAC-verbruik in $unit/min te tonen'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'Vliegverbod: nog $remaining'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..01ef1bdd8b 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -24023,6 +24023,11 @@ class AppLocalizationsPt extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => 'Este ponto registra uma altitude, mas o mergulho não tem nenhuma, então a análise de descompressão assumiu o nível do mar. Defina a altitude do mergulho para corrigir.'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return 'Adicione o volume do cilindro para mostrar a taxa SAC em $unit/min'; + } + @override String safetyHub_alert_noFly(String remaining) { return 'Não voar: faltam $remaining'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..3a9c48cfd9 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -22832,6 +22832,11 @@ class AppLocalizationsZh extends AppLocalizations { String get diveLog_detail_altitudeMismatch_subtitle => '该潜点记录了海拔,但此次潜水未设置海拔,因此减压分析按海平面计算。请设置潜水海拔以更正。'; + @override + String diveLog_detail_sacVolumeHint(String unit) { + return '添加气瓶容积以按 $unit/min 显示气体消耗率'; + } + @override String safetyHub_alert_noFly(String remaining) { return '禁飞:剩余 $remaining'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..4d1855b40a 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "Duikstek ligt op hoogte", "diveLog_detail_altitudeMismatch_subtitle": "Deze stek heeft een hoogte geregistreerd maar de duik niet, dus de deco-analyse ging uit van zeeniveau. Stel de hoogte van de duik in om dit te corrigeren.", + "diveLog_detail_sacVolumeHint": "Voeg een flesvolume toe om het SAC-verbruik in {unit}/min te tonen", "emergencyCard_title": "Noodgeval", "emergencyCard_callDan": "Bel {name}", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..c815154602 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "O ponto de mergulho fica em altitude", "diveLog_detail_altitudeMismatch_subtitle": "Este ponto registra uma altitude, mas o mergulho não tem nenhuma, então a análise de descompressão assumiu o nível do mar. Defina a altitude do mergulho para corrigir.", + "diveLog_detail_sacVolumeHint": "Adicione o volume do cilindro para mostrar a taxa SAC em {unit}/min", "emergencyCard_title": "Emergência", "emergencyCard_callDan": "Ligar para {name}", "@emergencyCard_callDan": { diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..8b49fa346a 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -7214,6 +7214,7 @@ }, "diveLog_detail_altitudeMismatch_title": "潜点位于高海拔", "diveLog_detail_altitudeMismatch_subtitle": "该潜点记录了海拔,但此次潜水未设置海拔,因此减压分析按海平面计算。请设置潜水海拔以更正。", + "diveLog_detail_sacVolumeHint": "添加气瓶容积以按 {unit}/min 显示气体消耗率", "emergencyCard_title": "紧急情况", "emergencyCard_callDan": "呼叫 {name}", "@emergencyCard_callDan": { From 7e052c80f747ae13469598ca0e53727019c81b10 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:59:40 -0400 Subject: [PATCH 072/122] fix(sac): make L/min SAC reachable on dive-computer downloads (#386) Volumetric SAC needs a cylinder volume, which dive computers never report, so on a downloaded dive the L/min preference silently produced nothing. Three gaps hid it: - The "Also apply to imported dives" default-tank toggle only reached file imports through the universal adapter. DiveImportService now takes a DefaultTankPresetLoader (composed in download_providers, read at import time) and fills volume, working pressure, material and preset name on back-gas cylinders that lack a size. It never fabricates a fill pressure and leaves deco, stage and rebreather bottles alone. TankData carries the new fields and importProfile writes them. - Reparse nulled a user-entered volume on every carry-over because the parse had none. It now only overwrites a volume the computer reported. - The Details SAC row hid itself entirely when L/min was selected and no cylinder had a volume. It now shows the pressure-per-minute value with a tappable hint that opens the dive editor; the SAC-by-segment card shows the same hint when it falls back. --- .../data/services/dive_import_service.dart | 43 ++++- .../services/downloaded_tank_defaults.dart | 48 ++++++ .../data/services/reparse_service.dart | 5 +- .../providers/download_providers.dart | 13 ++ .../dive_computer_repository_impl.dart | 16 ++ .../dive_log/domain/entities/dive.dart | 5 + .../presentation/pages/dive_detail_page.dart | 52 ++++-- .../presentation/widgets/sac_volume_hint.dart | 50 ++++++ .../services/dive_import_service_test.dart | 153 +++++++++++++++++ .../downloaded_tank_defaults_test.dart | 116 +++++++++++++ .../data/services/reparse_service_test.dart | 59 +++++++ .../dive_computer_repository_impl_test.dart | 33 ++++ .../pages/dive_detail_sac_row_test.dart | 97 ++++++++++- .../dive_detail_sac_segments_hint_test.dart | 162 ++++++++++++++++++ 14 files changed, 829 insertions(+), 23 deletions(-) create mode 100644 lib/features/dive_computer/data/services/downloaded_tank_defaults.dart create mode 100644 lib/features/dive_log/presentation/widgets/sac_volume_hint.dart create mode 100644 test/features/dive_computer/data/services/downloaded_tank_defaults_test.dart create mode 100644 test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart diff --git a/lib/features/dive_computer/data/services/dive_import_service.dart b/lib/features/dive_computer/data/services/dive_import_service.dart index 4a3804dd05..2a6ab80064 100644 --- a/lib/features/dive_computer/data/services/dive_import_service.dart +++ b/lib/features/dive_computer/data/services/dive_import_service.dart @@ -5,7 +5,9 @@ import 'package:submersion/features/dive_log/data/repositories/dive_repository_i import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; import 'package:submersion/features/dive_computer/data/services/dive_parser.dart'; +import 'package:submersion/features/dive_computer/data/services/downloaded_tank_defaults.dart'; import 'package:submersion/features/gps_log/data/services/gps_track_match_service.dart'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; /// Mode for importing dives. enum ImportMode { @@ -230,22 +232,42 @@ class ImportResult { int get totalProcessed => imported + skipped + updated; } +/// Supplies the default tank preset to fill downloaded cylinders with, or +/// null when the diver has not opted in (or the preset no longer exists). +typedef DefaultTankPresetLoader = Future Function(); + /// Service for importing downloaded dives into the app's database. class DiveImportService { final DiveComputerRepository _repository; final DiveRepository? _diveRepository; final DiveParser _parser; final GpsTrackMatchService? _gpsTrackMatchService; + final DefaultTankPresetLoader? _defaultTankPresetForImports; DiveImportService({ required DiveComputerRepository repository, DiveRepository? diveRepository, DiveParser? parser, GpsTrackMatchService? gpsTrackMatchService, + DefaultTankPresetLoader? defaultTankPresetForImports, }) : _repository = repository, _diveRepository = diveRepository, _parser = parser ?? const DiveParser(), - _gpsTrackMatchService = gpsTrackMatchService; + _gpsTrackMatchService = gpsTrackMatchService, + _defaultTankPresetForImports = defaultTankPresetForImports; + + /// The preset to fill downloaded cylinders with. + /// + /// [importDives] resolves it once for the whole batch. The wizard's + /// per-dive entry points ([importSingleDiveAsNew], [resolveConflict]) + /// resolve it per call: one small preset lookup beside a profile write of + /// thousands of samples, and deliberately not cached across calls so a + /// toggle flipped in Settings applies to the next download. + Future _loadDefaultTankPreset() async { + final loader = _defaultTankPresetForImports; + if (loader == null) return null; + return loader(); + } /// Import a list of downloaded dives. /// @@ -290,6 +312,8 @@ class DiveImportService { ? await _diveRepository.getSourceKeysByDiveId() : null; + final defaultTankPreset = await _loadDefaultTankPreset(); + for (final dive in sortedDives) { try { // Check for duplicates @@ -343,6 +367,7 @@ class DiveImportService { computer.id, diverId, forceNew: true, + defaultTankPreset: defaultTankPreset, descriptorVendor: descriptorVendor, descriptorProduct: descriptorProduct, descriptorModel: descriptorModel, @@ -372,6 +397,7 @@ class DiveImportService { computer.id, diverId, forceNew: true, + defaultTankPreset: defaultTankPreset, descriptorVendor: descriptorVendor, descriptorProduct: descriptorProduct, descriptorModel: descriptorModel, @@ -387,6 +413,7 @@ class DiveImportService { dive, computer.id, diverId, + defaultTankPreset: defaultTankPreset, descriptorVendor: descriptorVendor, descriptorProduct: descriptorProduct, descriptorModel: descriptorModel, @@ -499,11 +526,15 @@ class DiveImportService { } /// Import a downloaded dive as a new dive. + /// + /// [defaultTankPreset], when given, fills the cylinder size the computer + /// did not report so volumetric SAC is reachable on the new dive. Future _importNewDive( DownloadedDive dive, String computerId, String? diverId, { bool forceNew = false, + TankPresetEntity? defaultTankPreset, String? descriptorVendor, String? descriptorProduct, int? descriptorModel, @@ -521,8 +552,12 @@ class DiveImportService { // Parse profile data final profilePoints = _parser.parseProfile(dive); - // Convert tanks to TankData - final tanks = _parser.parseTanks(dive); + // Convert tanks to TankData, filling the cylinder size from the default + // preset when the diver opted in (computers report pressure, not size). + final parsedTanks = _parser.parseTanks(dive); + final tanks = defaultTankPreset == null + ? parsedTanks + : applyDefaultPresetToTanks(parsedTanks, defaultTankPreset); // Convert events to EventData final events = _convertEvents(dive.events); @@ -583,6 +618,7 @@ class DiveImportService { computerId, diverId, forceNew: true, + defaultTankPreset: await _loadDefaultTankPreset(), descriptorVendor: descriptorVendor, descriptorProduct: descriptorProduct, descriptorModel: descriptorModel, @@ -683,6 +719,7 @@ class DiveImportService { conflict.downloaded, computerId, diverId, + defaultTankPreset: await _loadDefaultTankPreset(), descriptorVendor: descriptorVendor, descriptorProduct: descriptorProduct, descriptorModel: descriptorModel, diff --git a/lib/features/dive_computer/data/services/downloaded_tank_defaults.dart b/lib/features/dive_computer/data/services/downloaded_tank_defaults.dart new file mode 100644 index 0000000000..31622b34cf --- /dev/null +++ b/lib/features/dive_computer/data/services/downloaded_tank_defaults.dart @@ -0,0 +1,48 @@ +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; + +/// Fill the physical cylinder attributes a dive computer never reports from +/// the diver's default tank [preset]. +/// +/// Downloads carry transmitter pressure but almost never cylinder size, which +/// leaves volumetric (L/min) SAC unreachable on every downloaded dive +/// (issue #386). This is the download-side counterpart of the file-import +/// fallback in `import_tank_defaults.dart`, with two deliberate differences: +/// +/// - It never fabricates a fill pressure. A download's pressures come from +/// the transmitter, so a non-AI dive stays pressureless rather than gaining +/// a fictitious 200 bar start. +/// - It only touches back-gas cylinders (or ones whose role was not +/// inferred). The default tank describes the diver's usual back gas; +/// stamping its size and label on a deco bottle or a CCR diluent would +/// fabricate a cylinder record and misconvert that segment's SAC. +/// +/// A volume the computer did report is left untouched, and no preset label +/// is attached to it; only a missing or zero size is filled. Returns a new +/// list. +List applyDefaultPresetToTanks( + List tanks, + TankPresetEntity preset, +) { + return tanks.map((tank) { + final volume = tank.volumeLiters; + final hasVolume = volume != null && volume > 0; + final isBackGas = tank.role == null || tank.role == TankRole.backGas.name; + if (hasVolume || !isBackGas) { + return tank; + } + return TankData( + index: tank.index, + o2Percent: tank.o2Percent, + hePercent: tank.hePercent, + startPressure: tank.startPressure, + endPressure: tank.endPressure, + volumeLiters: preset.volumeLiters, + workingPressure: tank.workingPressure ?? preset.workingPressureBar, + material: tank.material ?? preset.material.name, + presetName: tank.presetName ?? preset.name, + role: tank.role, + ); + }).toList(); +} diff --git a/lib/features/dive_computer/data/services/reparse_service.dart b/lib/features/dive_computer/data/services/reparse_service.dart index 017142e8af..411695eeb8 100644 --- a/lib/features/dive_computer/data/services/reparse_service.dart +++ b/lib/features/dive_computer/data/services/reparse_service.dart @@ -682,7 +682,10 @@ class ReparseService { db.diveTanks, )..where((t) => t.id.equals(existing.id))).write( DiveTanksCompanion( - volume: Value(tank.volumeLiters), + // Computers report pressure, not cylinder size: a volume the + // parse lacks was entered by the diver (or filled from the + // default preset), so only overwrite it with a reported one. + volume: Value.absentIfNull(tank.volumeLiters), workingPressure: const Value.absent(), startPressure: Value(tank.startPressure), endPressure: Value(tank.endPressure), diff --git a/lib/features/dive_computer/presentation/providers/download_providers.dart b/lib/features/dive_computer/presentation/providers/download_providers.dart index 9665f45ad2..ab31f8bd51 100644 --- a/lib/features/dive_computer/presentation/providers/download_providers.dart +++ b/lib/features/dive_computer/presentation/providers/download_providers.dart @@ -17,6 +17,9 @@ 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'; +import 'package:submersion/features/tank_presets/domain/services/default_tank_preset_resolver.dart'; +import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; /// Provider for the dive computer repository. final diveComputerRepositoryProvider = Provider((ref) { @@ -31,6 +34,16 @@ final diveImportServiceProvider = Provider((ref) { repository: repository, diveRepository: diveRepository, gpsTrackMatchService: ref.watch(gpsTrackMatchServiceProvider), + // Read at import time, not provider build time, so a toggle flipped in + // Settings applies to the very next download (issue #386). + defaultTankPresetForImports: () async { + final settings = ref.read(settingsProvider); + if (!settings.applyDefaultTankToImports) return null; + final resolver = DefaultTankPresetResolver( + repository: ref.read(tankPresetRepositoryProvider), + ); + return resolver.resolve(settings.defaultTankPreset); + }, ); }); diff --git a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart index 72906b3fa8..f8a9f117e7 100644 --- a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart @@ -1392,6 +1392,9 @@ class DiveComputerRepository { diveId: Value(diveId), computerId: Value(computerId), volume: Value(tank.volumeLiters), + workingPressure: Value.absentIfNull(tank.workingPressure), + tankMaterial: Value.absentIfNull(tank.material), + presetName: Value.absentIfNull(tank.presetName), startPressure: Value(tank.startPressure), endPressure: Value(tank.endPressure), o2Percent: Value(tank.o2Percent), @@ -2024,6 +2027,16 @@ class TankData { final double? endPressure; final double? volumeLiters; + /// Rated working pressure in bar, when known (from the default tank preset; + /// computers do not report it). + final double? workingPressure; + + /// Cylinder material (a `TankMaterial` name), when known. + final String? material; + + /// The tank preset the physical attributes came from, when they did. + final String? presetName; + /// Inferred cylinder role (a [TankRole] name), or null for the default. final String? role; @@ -2034,6 +2047,9 @@ class TankData { this.startPressure, this.endPressure, this.volumeLiters, + this.workingPressure, + this.material, + this.presetName, this.role, }); } diff --git a/lib/features/dive_log/domain/entities/dive.dart b/lib/features/dive_log/domain/entities/dive.dart index ef844b7957..f6fa96714a 100644 --- a/lib/features/dive_log/domain/entities/dive.dart +++ b/lib/features/dive_log/domain/entities/dive.dart @@ -415,6 +415,11 @@ class Dive extends Equatable { return totalGasLiters / minutes / avgPressureBar; } + /// Whether any cylinder carries a usable volume, the one input volumetric + /// (L/min) SAC needs and dive computers do not report (issue #386). + bool get hasCylinderVolume => + tanks.any((t) => t.volume != null && t.volume! > 0); + /// Air consumption rate in pressure units per minute (bar/min or psi/min) /// This is a simpler calculation that doesn't require tank volume. /// It calculates the average pressure drop per minute adjusted for depth. diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 6b0d6af1c8..7b1f47f216 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -83,6 +83,7 @@ import 'package:submersion/features/dive_log/presentation/widgets/playback_stats import 'package:submersion/features/dive_log/presentation/widgets/range_selection_overlay.dart'; import 'package:submersion/features/dive_log/presentation/widgets/range_stats_panel.dart'; import 'package:submersion/features/dive_log/presentation/widgets/responsive_section_pair.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/sac_volume_hint.dart'; import 'package:submersion/features/dive_log/presentation/widgets/source_bar.dart'; import 'package:submersion/features/dive_log/presentation/widgets/tissue_saturation_panel.dart'; import 'package:submersion/features/dive_roles/domain/entities/dive_role.dart'; @@ -2479,6 +2480,14 @@ class _DiveDetailPageState extends ConsumerState { ), ); }), + // Segments fell back to the pressure lane above: say why. + if (sacUnit == SacUnit.litersPerMin && tankVolume == null) ...[ + const SizedBox(height: 8), + SacVolumeHint( + volumeSymbol: units.volumeSymbol, + onTap: () => context.push('/dives/${dive.id}/edit'), + ), + ], ], ), ), @@ -4061,27 +4070,50 @@ class _DiveDetailPageState extends ConsumerState { if (sacUnit == SacUnit.litersPerMin) { // Volume-based SAC (L/min) - requires tank volume and a gas model final sac = dive.sacFor(ref.watch(gasModelProvider)); - if (sac == null) return const SizedBox.shrink(); - final value = - '${units.convertVolume(sac).toStringAsFixed(1)} ${units.volumeSymbol}/min'; - return _buildDetailRow( - context, - context.l10n.diveLog_detail_label_sacRate, - value, + if (sac != null) { + final value = + '${units.convertVolume(sac).toStringAsFixed(1)} ${units.volumeSymbol}/min'; + return _buildDetailRow( + context, + context.l10n.diveLog_detail_label_sacRate, + value, + ); + } + // No cylinder volume (the norm for dive-computer downloads): show the + // pressure lane and say why, rather than hiding the row and leaving + // the L/min preference looking broken (issue #386). With no pressure + // data either there is nothing to fall back to. + if (dive.hasCylinderVolume || dive.sacPressure == null) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + context, + context.l10n.diveLog_detail_label_sacRate, + _formatPressureSac(dive.sacPressure!, units), + ), + SacVolumeHint( + volumeSymbol: units.volumeSymbol, + onTap: () => context.push('/dives/${dive.id}/edit'), + ), + ], ); } else { // Pressure-based SAC (bar/min or psi/min) - doesn't require tank volume if (dive.sacPressure == null) return const SizedBox.shrink(); - final value = - '${units.convertPressure(dive.sacPressure!).toStringAsFixed(1)} ${units.pressureSymbol}/min'; return _buildDetailRow( context, context.l10n.diveLog_detail_label_sacRate, - value, + _formatPressureSac(dive.sacPressure!, units), ); } } + String _formatPressureSac(double sacPressure, UnitFormatter units) => + '${units.convertPressure(sacPressure).toStringAsFixed(1)} ${units.pressureSymbol}/min'; + Widget _buildDetailRow( BuildContext context, String label, diff --git a/lib/features/dive_log/presentation/widgets/sac_volume_hint.dart b/lib/features/dive_log/presentation/widgets/sac_volume_hint.dart new file mode 100644 index 0000000000..21c7559d64 --- /dev/null +++ b/lib/features/dive_log/presentation/widgets/sac_volume_hint.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Explains why SAC is shown per pressure unit when the diver asked for +/// volume per minute: no cylinder on the dive has a volume (issue #386). +/// +/// Dive-computer downloads carry transmitter pressure but not cylinder size, +/// so without this note the L/min preference looked broken on every imported +/// dive. Tapping the hint (when [onTap] is given) opens the dive editor, where +/// the cylinder volume lives. +class SacVolumeHint extends StatelessWidget { + const SacVolumeHint({super.key, required this.volumeSymbol, this.onTap}); + + /// The diver's volume unit symbol (e.g. "L" or "cuft"). + final String volumeSymbol; + + /// Opens the place the volume can be entered; null renders a plain note. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final muted = theme.colorScheme.onSurfaceVariant; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: muted), + const SizedBox(width: 6), + Expanded( + child: Text( + context.l10n.diveLog_detail_sacVolumeHint(volumeSymbol), + style: theme.textTheme.bodySmall?.copyWith(color: muted), + ), + ), + if (onTap != null) ...[ + const SizedBox(width: 6), + Icon(Icons.edit_outlined, size: 16, color: muted), + ], + ], + ), + ), + ); + } +} diff --git a/test/features/dive_computer/data/services/dive_import_service_test.dart b/test/features/dive_computer/data/services/dive_import_service_test.dart index e3c038cf33..9f36bc4cca 100644 --- a/test/features/dive_computer/data/services/dive_import_service_test.dart +++ b/test/features/dive_computer/data/services/dive_import_service_test.dart @@ -7,7 +7,9 @@ import 'package:submersion/features/dive_computer/data/services/dive_import_serv import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/core/constants/tank_presets.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_computer.dart'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; @GenerateMocks([DiveComputerRepository, DiveRepository]) import 'dive_import_service_test.mocks.dart'; @@ -896,4 +898,155 @@ void main() { }, ); }); + + group('default tank preset for downloads (issue #386)', () { + final al80 = TankPresetEntity.fromBuiltIn(TankPresets.al80); + + DownloadedDive diveWithPressureOnlyTank() => DownloadedDive( + fingerprint: 'fp-al', + startTime: DateTime(2026, 2, 1, 9, 0), + durationSeconds: 2700, + maxDepth: 18.0, + profile: const [], + tanks: const [ + DownloadedTank( + index: 0, + o2Percent: 21.0, + startPressure: 200.0, + endPressure: 60.0, + ), + ], + events: const [], + ); + + List importedTanks() { + final captured = verify( + mockComputerRepo.importProfile( + computerId: anyNamed('computerId'), + profileStartTime: anyNamed('profileStartTime'), + points: anyNamed('points'), + durationSeconds: anyNamed('durationSeconds'), + maxDepth: anyNamed('maxDepth'), + avgDepth: anyNamed('avgDepth'), + isPrimary: anyNamed('isPrimary'), + diverId: anyNamed('diverId'), + tanks: captureAnyNamed('tanks'), + decoAlgorithm: anyNamed('decoAlgorithm'), + gfLow: anyNamed('gfLow'), + gfHigh: anyNamed('gfHigh'), + decoConservatism: anyNamed('decoConservatism'), + events: anyNamed('events'), + gasSwitches: anyNamed('gasSwitches'), + diveNumber: anyNamed('diveNumber'), + forceNew: anyNamed('forceNew'), + rawData: anyNamed('rawData'), + rawFingerprint: anyNamed('rawFingerprint'), + descriptorVendor: anyNamed('descriptorVendor'), + descriptorProduct: anyNamed('descriptorProduct'), + descriptorModel: anyNamed('descriptorModel'), + libdivecomputerVersion: anyNamed('libdivecomputerVersion'), + ), + ).captured; + return captured.single as List; + } + + setUp(() { + when( + mockDiveRepo.getDiveNumberForDate(any, diverId: anyNamed('diverId')), + ).thenAnswer((_) async => 1); + }); + + test( + 'fills the cylinder size from the preset when one is supplied', + () async { + service = DiveImportService( + repository: mockComputerRepo, + diveRepository: mockDiveRepo, + defaultTankPresetForImports: () async => al80, + ); + + await service.importDives( + dives: [diveWithPressureOnlyTank()], + computer: computer, + ); + + final tanks = importedTanks(); + expect(tanks.single.volumeLiters, al80.volumeLiters); + expect(tanks.single.presetName, 'al80'); + // The transmitter's pressures are untouched. + expect(tanks.single.startPressure, 200.0); + expect(tanks.single.endPressure, 60.0); + }, + ); + + test('leaves the tank alone when the loader yields no preset', () async { + // The toggle is off, or the configured preset no longer exists. + service = DiveImportService( + repository: mockComputerRepo, + diveRepository: mockDiveRepo, + defaultTankPresetForImports: () async => null, + ); + + await service.importDives( + dives: [diveWithPressureOnlyTank()], + computer: computer, + ); + + expect(importedTanks().single.volumeLiters, isNull); + }); + + test('leaves the tank alone without a loader', () async { + await service.importDives( + dives: [diveWithPressureOnlyTank()], + computer: computer, + ); + + expect(importedTanks().single.volumeLiters, isNull); + }); + + test('applies to the explicit import-as-new path too', () async { + service = DiveImportService( + repository: mockComputerRepo, + diveRepository: mockDiveRepo, + defaultTankPresetForImports: () async => al80, + ); + + await service.importSingleDiveAsNew( + diveWithPressureOnlyTank(), + computerId: computer.id, + ); + + expect(importedTanks().single.volumeLiters, al80.volumeLiters); + }); + + test('resolves the preset once per batch', () async { + var loads = 0; + service = DiveImportService( + repository: mockComputerRepo, + diveRepository: mockDiveRepo, + defaultTankPresetForImports: () async { + loads++; + return al80; + }, + ); + + await service.importDives( + dives: [ + diveWithPressureOnlyTank(), + DownloadedDive( + fingerprint: 'fp-second', + startTime: DateTime(2026, 2, 2, 9, 0), + durationSeconds: 2700, + maxDepth: 18.0, + profile: const [], + tanks: const [DownloadedTank(index: 0, o2Percent: 21.0)], + events: const [], + ), + ], + computer: computer, + ); + + expect(loads, 1); + }); + }); } diff --git a/test/features/dive_computer/data/services/downloaded_tank_defaults_test.dart b/test/features/dive_computer/data/services/downloaded_tank_defaults_test.dart new file mode 100644 index 0000000000..1868a2397d --- /dev/null +++ b/test/features/dive_computer/data/services/downloaded_tank_defaults_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/constants/tank_presets.dart'; +import 'package:submersion/features/dive_computer/data/services/downloaded_tank_defaults.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; + +/// Dive computers report cylinder pressure but almost never cylinder size, so +/// a downloaded tank has no volume and volumetric (L/min) SAC is unreachable +/// (issue #386). When the diver has opted in, the default tank preset fills +/// the physical cylinder attributes on the way in. +void main() { + final al80 = TankPresetEntity.fromBuiltIn(TankPresets.al80); + + test('fills volume, working pressure, material and preset name', () { + const tank = TankData( + index: 0, + o2Percent: 21.0, + startPressure: 200.0, + endPressure: 50.0, + ); + + final result = applyDefaultPresetToTanks([tank], al80); + + expect(result, hasLength(1)); + expect(result.first.volumeLiters, al80.volumeLiters); + expect(result.first.workingPressure, al80.workingPressureBar); + expect(result.first.material, TankMaterial.aluminum.name); + expect(result.first.presetName, 'al80'); + }); + + test('keeps a volume the computer reported', () { + const tank = TankData(index: 0, o2Percent: 21.0, volumeLiters: 12.0); + + final result = applyDefaultPresetToTanks([tank], al80); + + expect(result.first.volumeLiters, 12.0); + // A computer-reported size is not the preset, so it is not labeled as one. + expect(result.first.presetName, isNull); + }); + + test('treats a zero volume as missing', () { + const tank = TankData(index: 0, o2Percent: 21.0, volumeLiters: 0.0); + + final result = applyDefaultPresetToTanks([tank], al80); + + expect(result.first.volumeLiters, al80.volumeLiters); + }); + + test('never fabricates a fill pressure', () { + // Unlike file imports, a download's pressures come from the transmitter; + // a non-AI dive must stay pressureless rather than show a 200 bar start. + const tank = TankData(index: 0, o2Percent: 32.0); + + final result = applyDefaultPresetToTanks([tank], al80); + + expect(result.first.startPressure, isNull); + expect(result.first.endPressure, isNull); + }); + + test('preserves the fields that identify the cylinder', () { + const tank = TankData( + index: 2, + o2Percent: 32.0, + hePercent: 10.0, + startPressure: 180.0, + endPressure: 120.0, + role: 'backGas', + ); + + final result = applyDefaultPresetToTanks([tank], al80); + + expect(result.first.index, 2); + expect(result.first.o2Percent, 32.0); + expect(result.first.hePercent, 10.0); + expect(result.first.startPressure, 180.0); + expect(result.first.endPressure, 120.0); + expect(result.first.role, 'backGas'); + }); + + test('fills a cylinder whose role was not inferred', () { + const tank = TankData(index: 0, o2Percent: 21.0); + + final result = applyDefaultPresetToTanks([tank], al80); + + expect(result.first.volumeLiters, al80.volumeLiters); + }); + + test('leaves stage, deco and rebreather bottles alone', () { + // The default tank describes the diver's usual back gas. Stamping its + // size and label on a deco bottle or a CCR diluent would fabricate a + // cylinder record and misconvert that segment's SAC. + const bottles = [ + TankData(index: 1, o2Percent: 50.0, role: 'deco'), + TankData(index: 2, o2Percent: 21.0, role: 'stage'), + TankData(index: 3, o2Percent: 21.0, role: 'diluent'), + TankData(index: 4, o2Percent: 100.0, role: 'oxygenSupply'), + ]; + + final result = applyDefaultPresetToTanks(bottles, al80); + + for (final tank in result) { + expect(tank.volumeLiters, isNull, reason: 'role ${tank.role}'); + expect(tank.presetName, isNull, reason: 'role ${tank.role}'); + } + }); + + test('does not mutate the input list', () { + const tank = TankData(index: 0, o2Percent: 21.0); + final input = [tank]; + + applyDefaultPresetToTanks(input, al80); + + expect(input.first.volumeLiters, isNull); + }); +} diff --git a/test/features/dive_computer/data/services/reparse_service_test.dart b/test/features/dive_computer/data/services/reparse_service_test.dart index 0fd5136dbd..32f81f6246 100644 --- a/test/features/dive_computer/data/services/reparse_service_test.dart +++ b/test/features/dive_computer/data/services/reparse_service_test.dart @@ -1863,6 +1863,65 @@ void main() { expect(tanks.first.tankName, 'User Named Tank'); }); + test('DiveTanks carry-over keeps a stored volume the computer does not ' + 'report', () async { + // Computers report pressure, not cylinder size, so a volume on the row + // was entered by the diver (or filled from the default preset). A + // re-parse must not null it out and make L/min SAC vanish (issue #386). + await insertDive('dive-1'); + await insertComputer('comp-1'); + await insertSource( + id: 'src-1', + diveId: 'dive-1', + computerId: 'comp-1', + isPrimary: true, + ); + await db + .into(db.diveTanks) + .insert( + const DiveTanksCompanion( + id: Value('tank-0'), + diveId: Value('dive-1'), + volume: Value(12.0), + startPressure: Value(200.0), + endPressure: Value(50.0), + o2Percent: Value(21.0), + hePercent: Value(0.0), + tankOrder: Value(0), + ), + ); + + final parsed = makeParsedDive( + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 210.0, + endPressureBar: 40.0, + ), + ], + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 21.0, hePercent: 0.0)], + ); + + await service.applyParsedUpdate( + diveId: 'dive-1', + sourceRowId: 'src-1', + parsed: parsed, + descriptorVendor: null, + descriptorProduct: null, + descriptorModel: null, + libdivecomputerVersion: null, + ); + + final tank = await (db.select( + db.diveTanks, + )..where((t) => t.diveId.equals('dive-1'))).getSingle(); + // Pressures follow the computer; the size the computer never saw stays. + expect(tank.startPressure, 210.0); + expect(tank.endPressure, 40.0); + expect(tank.volume, 12.0); + }); + test('non-primary source skips tank carry-over', () async { // Arrange: two sources, re-parse the non-primary one await insertDive('dive-1'); diff --git a/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart b/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart index 2a6da42f5e..f3a71524f3 100644 --- a/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart +++ b/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart @@ -845,6 +845,39 @@ void main() { expect(tank.o2Percent, 99.0); }); + test('persists the preset-derived cylinder attributes', () async { + // The default tank preset fills size, rated pressure, material and the + // preset label on downloaded cylinders (issue #386); the insert must + // carry all four, not just the volume. + final computerId = await insertComputer(); + + final diveId = await repository.importProfile( + computerId: computerId, + profileStartTime: DateTime(2026, 5, 3, 10, 0), + points: const [ProfilePointData(timestamp: 0, depth: 0.0)], + durationSeconds: 1800, + maxDepth: 18.0, + tanks: const [ + TankData( + index: 0, + o2Percent: 21.0, + volumeLiters: 11.1, + workingPressure: 207.0, + material: 'aluminum', + presetName: 'al80', + ), + ], + ); + + final tank = await (db.select( + db.diveTanks, + )..where((t) => t.diveId.equals(diveId))).getSingle(); + expect(tank.volume, 11.1); + expect(tank.workingPressure, 207.0); + expect(tank.tankMaterial, 'aluminum'); + expect(tank.presetName, 'al80'); + }); + test('replace-source: links a gas switch by gas mix even when the stored ' 'tank order differs from the parsed cylinder index', () async { // Regression for the re-download path: existing cylinders are kept (not diff --git a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart index b17eddcffc..3cae2e65ef 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart @@ -9,39 +9,46 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/presentation/pages/dive_detail_page.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/sac_volume_hint.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; import '../../../../helpers/mock_providers.dart'; -/// The dive detail SAC row honors the gas model preference (issue #828). +/// The dive detail SAC row honors the gas model preference (issue #828) and, +/// when volumetric SAC is selected but no cylinder has a volume, falls back +/// to the pressure lane with a hint instead of vanishing (issue #386). /// -/// The volumetric lane is the only one that can differ; bar/min is a pressure -/// drop and carries no equation of state. +/// The volumetric lane is the only one that can differ by gas model; bar/min +/// is a pressure drop and carries no equation of state. void main() { /// The issue's cylinder: 12 L, 200 -> 50 bar, 44 min, 13.2 m average. /// Ideal reads 17.6 L/min, real reads 16.8. - Dive reportedDive() { + Dive reportedDive({double? volume = 12.0}) { return createTestDiveWithBottomTime( runtime: const Duration(minutes: 44), avgDepth: 13.2, ).copyWith( - tanks: const [ + tanks: [ DiveTank( id: 'tank-1', - volume: 12.0, + volume: volume, startPressure: 200.0, endPressure: 50.0, - gasMix: GasMix(o2: 21.0, he: 0.0), + gasMix: const GasMix(o2: 21.0, he: 0.0), role: TankRole.backGas, ), ], ); } - Future pumpWith(WidgetTester tester, AppSettings settings) async { - final dive = reportedDive(); + Future pumpWith( + WidgetTester tester, + AppSettings settings, { + Dive? dive, + }) async { + dive ??= reportedDive(); SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -75,6 +82,9 @@ void main() { await tester.pump(const Duration(seconds: 1)); } + AppLocalizations l10nOf(WidgetTester tester) => + AppLocalizations.of(tester.element(find.byType(DiveDetailPage))); + testWidgets('volumetric SAC reads the ideal value when ideal is selected', ( tester, ) async { @@ -87,6 +97,7 @@ void main() { ); expect(find.text('17.6 L/min'), findsOneWidget); + expect(find.byType(SacVolumeHint), findsNothing); }); testWidgets('volumetric SAC reads the real value when real is selected', ( @@ -111,4 +122,72 @@ void main() { expect(find.text('1.5 bar/min'), findsOneWidget); } }); + + group('volumetric SAC without a cylinder volume (issue #386)', () { + testWidgets('falls back to the pressure lane and says why', (tester) async { + // A dive-computer download: transmitter pressures, no cylinder size. + await pumpWith( + tester, + const AppSettings(sacUnit: SacUnit.litersPerMin), + dive: reportedDive(volume: null), + ); + + expect(find.text('1.5 bar/min'), findsOneWidget); + expect( + find.text(l10nOf(tester).diveLog_detail_sacVolumeHint('L')), + findsOneWidget, + ); + }); + + testWidgets('names the diver\'s own volume unit in the hint', ( + tester, + ) async { + await pumpWith( + tester, + const AppSettings( + sacUnit: SacUnit.litersPerMin, + volumeUnit: VolumeUnit.cubicFeet, + ), + dive: reportedDive(volume: null), + ); + + expect( + find.text(l10nOf(tester).diveLog_detail_sacVolumeHint('cuft')), + findsOneWidget, + ); + }); + + testWidgets('shows no hint in the pressure lane', (tester) async { + await pumpWith( + tester, + const AppSettings(sacUnit: SacUnit.pressurePerMin), + dive: reportedDive(volume: null), + ); + + expect(find.text('1.5 bar/min'), findsOneWidget); + expect(find.byType(SacVolumeHint), findsNothing); + }); + + testWidgets('hides the row when there is no pressure data either', ( + tester, + ) async { + final dive = reportedDive(volume: null).copyWith( + tanks: const [ + DiveTank(id: 'tank-1', gasMix: GasMix(), role: TankRole.backGas), + ], + ); + await pumpWith( + tester, + const AppSettings(sacUnit: SacUnit.litersPerMin), + dive: dive, + ); + + // Nothing to fall back to, so a hint about volume would mislead. + expect( + find.text(l10nOf(tester).diveLog_detail_label_sacRate), + findsNothing, + ); + expect(find.byType(SacVolumeHint), findsNothing); + }); + }); } diff --git a/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart b/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart new file mode 100644 index 0000000000..1e53031715 --- /dev/null +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/data/services/profile_analysis_service.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_data_source.dart'; +import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; +import 'package:submersion/features/dive_log/domain/entities/source_profile.dart'; +import 'package:submersion/features/dive_log/presentation/pages/dive_detail_page.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/gas_analysis_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/gas_switch_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/profile_analysis_provider.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/collapsible_section.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/sac_volume_hint.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +/// The SAC-by-segment card converts its bar/min segments to L/min with the +/// dive's cylinder volume. Without one it silently showed bar/min under an +/// L/min preference (issue #386); now it says so. +void main() { + Dive diveWithProfile({double? tankVolume}) { + return createTestDiveWithBottomTime().copyWith( + profile: List.generate( + 6, + (i) => DiveProfilePoint( + timestamp: i * 60, + depth: (i < 3 ? i * 8.0 : (5 - i) * 8.0), + ), + ), + tanks: [ + DiveTank( + id: 'tank-1', + volume: tankVolume, + startPressure: 200.0, + endPressure: 50.0, + gasMix: const GasMix(), + role: TankRole.backGas, + ), + ], + ); + } + + ProfileAnalysis analysisWithSacSegments() { + return ProfileAnalysis.empty().copyWith( + sacSegments: const [ + SacSegment( + startTimestamp: 0, + endTimestamp: 300, + avgDepth: 18.0, + minDepth: 0.0, + maxDepth: 24.0, + sacRate: 0.8, + gasConsumed: 4.0, + segmentationType: SacSegmentationType.timeInterval, + ), + ], + ); + } + + Future pumpWith( + WidgetTester tester, { + required Dive dive, + required AppSettings settings, + }) async { + final base = await getBaseOverrides( + settingsNotifier: MockSettingsNotifier(settings), + ); + final originalOnError = FlutterError.onError; + addTearDown(() => FlutterError.onError = originalOnError); + FlutterError.onError = (d) { + if (d.toString().contains('overflowed')) return; + originalOnError?.call(d); + }; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...base, + diveProvider(dive.id).overrideWith((ref) async => dive), + diveDataSourcesProvider( + dive.id, + ).overrideWith((ref) async => []), + profileAnalysisProvider( + dive.id, + ).overrideWith((ref) async => analysisWithSacSegments()), + selectedSegmentationProvider.overrideWith( + (ref) => SacSegmentationType.timeInterval, + ), + gasSwitchesProvider( + dive.id, + ).overrideWith((ref) async => []), + tankPressuresProvider( + dive.id, + ).overrideWith((ref) async => >{}), + sourceProfilesProvider( + dive.id, + ).overrideWith((ref) async => {}), + weeklyOtuProvider(dive.id).overrideWith((ref) async => 0.0), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DiveDetailPage(diveId: dive.id, embedded: true), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + + Finder hintInSacCard(WidgetTester tester) { + final l10n = AppLocalizations.of( + tester.element(find.byType(DiveDetailPage)), + ); + final card = find.widgetWithText( + CollapsibleCardSection, + l10n.diveLog_detail_section_sacRateBySegment, + ); + expect(card, findsOneWidget); + return find.descendant(of: card, matching: find.byType(SacVolumeHint)); + } + + testWidgets('explains the bar/min fallback when L/min is selected', ( + tester, + ) async { + await pumpWith( + tester, + dive: diveWithProfile(), + settings: const AppSettings(sacUnit: SacUnit.litersPerMin), + ); + + expect(hintInSacCard(tester), findsOneWidget); + }); + + testWidgets('shows no hint once the cylinder has a volume', (tester) async { + await pumpWith( + tester, + dive: diveWithProfile(tankVolume: 12.0), + settings: const AppSettings(sacUnit: SacUnit.litersPerMin), + ); + + expect(hintInSacCard(tester), findsNothing); + }); + + testWidgets('shows no hint under a pressure-per-minute preference', ( + tester, + ) async { + await pumpWith( + tester, + dive: diveWithProfile(), + settings: const AppSettings(sacUnit: SacUnit.pressurePerMin), + ); + + expect(hintInSacCard(tester), findsNothing); + }); +} From 2c3e1fdaf6223ead62bd56c1edb0f677983205d7 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 00:58:03 -0400 Subject: [PATCH 073/122] docs(import): design for Subsurface media import Closes the last open thread of the #153 umbrella. Resolution reuses the existing media repair ladder (detectPrefixMove plus buildRepairProposals) rather than adding a second path matcher, so a picked media root re-roots the foreign absolute paths Subsurface exports. Refs #1147 --- ...-08-26-subsurface-picture-import-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md diff --git a/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md b/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md new file mode 100644 index 0000000000..db901f5ee1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md @@ -0,0 +1,258 @@ +# Subsurface Picture Import: Design + +**Status:** approved 2026-08-26 +**Issue:** #1147 (split out of #153) +**Branch:** `worktree-issue-1147-subsurface-picture-import` +**Supersedes nothing.** Closes the last open thread of the #153 umbrella; the +coordinate and duplicate-site threads landed in #1146. + +## Problem + +Subsurface writes one `` element per attached photo inside each +``: + +```xml + +``` + +Nothing in the app reads it. A user migrating a Subsurface logbook gets their +dives and sites but silently loses every photo association they had built up. + +The hard part is not parsing. Subsurface stores an absolute path from the +machine that produced the export, so `/home/jai/Pictures/...` will not exist on +the importing device and on iOS or Android cannot exist at all. Import needs a +path-resolution strategy, and the resolution result needs to be visible to the +user rather than quietly reducing to a subset. + +## Findings + +Every claim below was verified against this branch at d32723a5807. + +**F1. Media cannot be carried by a payload at all.** `ImportEntityType` is +declared twice, at `import_enums.dart:261` (the parser-facing enum, aliased +`ui.` in the adapter) and `import_bundle.dart:25` (the wizard-facing enum, +aliased `wizard.`). Neither has a media member, so `ImportPayload.entities` +has no key a parser could file pictures under. This is an absence, not a bug. + +**F2. No parser touches media.** `grep -rn "'picture'"` over +`lib/features/universal_import/` returns nothing. + +**F3. The one existing photo path is narrow and does not fit.** +`ZipExpansionService` collects `.jpg/.jpeg/.png/.heic/.heif` members next to a +dive file, and `universal_adapter.dart:919` attaches them only when the source +file produced exactly one dive. Two things make this unusable for Subsurface: +the gate excludes any multi-dive logbook, and `zip_expansion_service.dart:48` +sets `_diveFileExtensions = {'.zxu', '.zxl'}`, so an `.ssrf` inside a ZIP is +discarded as junk before the gate is ever reached. Fixing the gate alone would +not help. + +**F4. The resolution ladder we need already exists.** +`media_repair_matcher.dart:8` `detectPrefixMove({brokenPaths, foundPaths})` +votes on `(fromPrefix, toPrefix)` pairs over shared trailing segments and +returns the pair covering the most paths, requiring at least two covered paths +so a single coincidental filename is not read as evidence of a move. It votes +at every shared suffix length, not only the longest, so a folder name common to +both sides cannot mask the true move root. `media_repair_matcher.dart:83` +`buildRepairProposals(...)` then runs prefix-move first and a lowercase +basename index second, emitting `RepairConfidence.unmatched` when neither hits. +`folder_candidate_source.dart:14` `FolderCandidateSource` supplies the +recursive scan that builds that index. + +**F5. Those three are pure and operate on `MediaItem`, not on a bespoke type.** +`_filenameOf` (`media_repair_matcher.dart:149`) reads `originalFilename` and +falls back to the basename of `localPath ?? filePath`. A parsed `` has +exactly those two facts, so it can be dressed as a transient unsaved +`MediaItem` and fed through the existing ladder unchanged. + +**F6. `MediaItem` already models the GPS attribute.** +`media_item.dart:73` and `:74` declare `latitude` and `longitude`. The writer +does not pass them: `media_import_service.dart:70` `importLocalFileForDive` +takes only `sourceFile`, `diveId` and `takenAt`, hardcodes `MediaType.photo`, +and always copies into a `scanned_logs/` subdirectory named for the OCR flow +that introduced it. + +**F7. The wizard has no post-review step slot.** +`import_source_adapter.dart:38` documents acquisition steps as shown *before* +the shared Review, Import and Summary steps, and `calculateNextPage` in +`step_skip_calculator.dart` only advances while `nextPage < reviewIndex`. A +step between Review and Import is not expressible without changing the wizard +shell. `universal_adapter.dart:176` currently declares three acquisition steps: +Select File, Confirm Source, Map Fields. + +**F8. The offset attribute has no native column yet.** +`database.dart:3168` pins `currentSchemaVersion = 161`, and `grep -rn +"manualElapsed"` over `lib/` returns nothing. The `media.manual_elapsed_seconds` +column (v162) that models exactly this quantity is still in the open PR #1287. + +## Design + +### D1. Reuse the repair ladder rather than writing a resolver + +The chosen strategy is a user-picked media root, re-rooting absolute paths by +their longest shared trailing segments, falling back to a basename match across +the picked tree, with anything left over reported as not found. F4 and F5 +establish that this ladder is already implemented and already pure. The design +therefore adds no matching logic: + +``` +FolderCandidateSource(roots: [pickedRoot]).harvest(transientItems) + -> detectPrefixMove(brokenPaths, foundPaths) + -> buildRepairProposals(transientItems, byFilename, prefixMove, foundPaths) + -> one RepairProposal per picture +``` + +`RepairConfidence` maps onto the import vocabulary directly: `probable` via +`viaPrefixMove` is a clean re-root, `probable` without it is a filename-only +match, and `unmatched` is a picture we could not find. The `exact` and `edited` +rungs need content hashes and cannot arise here, since a `` element +carries no hash; the code must not assume they are reachable but also must not +special-case them away. + +The benefit beyond volume of code is that improvements to the repair ladder +accrue to import automatically, and the two features cannot drift apart in how +they interpret a moved photo library. + +### D2. Payload slot + +Add `media` to both enums from F1, with `displayName` "Photos" and `shortName` +"Photos". Dart's exhaustive switches will locate every site that must handle +the new member; the ui-to-wizard mapping in `universal_adapter` gains the pair. + +A payload media entry is a `Map` in keeping with the existing +convention documented on `ImportPayload`: + +| Key | Meaning | +| --- | --- | +| `filename` | the foreign absolute path, verbatim from the attribute | +| `offsetSeconds` | signed seconds from dive start, null when absent | +| `latitude`, `longitude` | from the `gps` attribute, null when absent | +| `_diveIndex` | index of the owning dive within the payload's dive list | + +`_diveIndex` follows the existing underscore convention for adapter-internal +stamps such as `_sourceFileId`. + +`PayloadMerger` gains media handling so a multi-file batch concatenates media +lists while rebasing each `_diveIndex` onto the merged dive list. Getting this +wrong would attach a photo to the wrong dive, so it is tested directly. + +### D3. Parser + +`subsurface_xml_parser.dart` gains `_parsePictures(XmlElement dive)`, called +from the same place `_collectTags` and `_collectBuddies` are called today, on +both dive-collection paths (the parser walks dives at two sites, `:108` and +`:141`, and missing one is the obvious defect to guard against). + +Offset parsing handles the signed `M:SS min` form including a negative offset, +which Subsurface writes for a photo taken before the dive started. An +unparseable offset yields null rather than dropping the picture: the file is +still worth importing, only its timestamp is unknown. + +A picture with an empty or absent `filename` is dropped with an +`ImportWarning`, since there is nothing to resolve. + +### D4. Resolver + +New `ImportMediaResolver` under +`lib/features/universal_import/domain/services/`. It is deliberately +format-agnostic and knows nothing about Subsurface: it takes payload media maps +plus a root path and returns an `ImportMediaResolution` holding the resolved +path per picture index, plus counts for re-rooted, filename-only and not-found. +UDDF's `` into `` can later feed the same resolver by +adding only a parser. + +The transient `MediaItem`s it constructs are never persisted. They exist for +the duration of one resolve call purely to satisfy the ladder's parameter type. + +### D5. Wizard placement + +Per F7, the Photos step is a fourth acquisition step in +`universal_adapter.acquisitionSteps`, after Map Fields and before Review. It +follows the Map Fields precedent of a stricter auto-advance condition than its +Next condition: + +- `canAdvance`: true once the user has picked a root, or explicitly chosen to + skip photos. Never blocks the import on a folder the user does not have. +- `canAutoAdvance`: true only when the parsed payload carries zero pictures, so + the step is invisible for every import that has no photos in it, and is never + auto-skipped past a decision the user still needs to make. + +`buildBundle()` then adds a media `EntityGroup`, so Review displays the photos +alongside dives and sites with the match counts already resolved. This is a +better outcome than the originally sketched post-review step: the resolution +result becomes part of what the user reviews rather than something decided +after review. + +### D6. Commit + +`importLocalFileForDive` gains optional `latitude`, `longitude` and a +destination subdirectory parameter defaulting to the current `scanned_logs`, so +the OCR caller is unaffected while imported photos land in their own directory. + +The adapter attaches each resolved picture to the dive id created for its +`_diveIndex`, reusing the existing `result.diveIdByIndex` map and skipping any +dive folded away by consolidation, exactly as `attachImportedPhotos` does +today. `takenAt` is the dive's `dateTime` plus `offsetSeconds` when both are +known, and the dive's `dateTime` otherwise. + +`offsetSeconds` is retained on the payload map even though only `takenAt` +consumes it now. When #1287 lands `media.manual_elapsed_seconds` (F8), adopting +it is a single additional field on the write, with no rework of the parser or +resolver. + +### D7. Platform scope + +Desktop only. The resolver needs real filesystem paths, which Android's SAF +does not reliably provide and iOS does not expose at all. + +On mobile the Photos step still appears whenever the payload carries pictures, +but instead of a folder picker it states the picture count and that importing +them requires running the import on desktop, and confirms that dives and sites +import normally. The deliberate choice here is that a mobile user is told what +is being left behind rather than silently receiving a subset. + +## Error handling + +An unreadable subtree under the picked root yields a partial harvest and a +logged warning, which is `FolderCandidateSource`'s existing behavior; the +pictures it would have covered simply report as not found. + +A per-photo copy failure at commit is counted and surfaced in the summary. This +is a deliberate tightening relative to `attachImportedPhotos`, whose +`catch (_)` currently swallows the failure entirely. The dive import must still +not fail because a photo copy did: the failure is reported, not thrown. + +Resolution never blocks the import. A user who cancels the folder picker +proceeds with dives and sites and no photos. + +## Testing + +Test-driven, in this order. + +1. Parser unit tests against a new `.ssrf` fixture carrying `` + elements: absolute POSIX and Windows paths, a positive offset, a negative + offset, a malformed offset, gps present and absent, and a picture on the + second of the parser's two dive-walk paths. +2. Resolver tests over a real temp directory tree: clean whole-tree re-root, + reorganised tree resolved by basename only, a picture present nowhere, and + an ambiguous basename appearing twice. +3. `PayloadMerger` test: two files each with pictures, asserting `_diveIndex` + rebasing keeps every photo on its own dive. +4. Widget tests: step auto-skipped with zero pictures, shown otherwise, mobile + message rendered on a mobile platform override. +5. Adapter test: resolved photos land on the correct dive ids, a consolidated + away dive drops its photos, and counts reach the summary. + +## Out of scope + +ZIP sidecar photos, Android SAF, iOS, the UDDF `` parser, per-file +disambiguation UI for ambiguous basename matches, and video. The resolver is +written format-agnostic (D4) so UDDF is later a parser change only. + +## Follow-ups + +- Adopt `media.manual_elapsed_seconds` for `offsetSeconds` once #1287 merges. +- Add the UDDF `` parser against the same resolver. +- Revisit ZIP sidecars, which need `_diveFileExtensions` widened (F3) and + therefore touch the DiveCloud archive path. From d077c9eb8e6f5eb2112a8b60dfa801a71509ad33 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:12:11 -0400 Subject: [PATCH 074/122] docs(import): implementation plan for Subsurface picture import Eight-task TDD plan. Also records a finding the design pass missed: the media repair ladder finds basenames with lastIndexOf('/') on both sides, so it misses everything on a Windows host and on any logbook exported from Windows. The plan fixes the harvest in place and normalises the foreign side in the resolver. Refs #1147 --- .../2026-08-26-subsurface-picture-import.md | 2094 +++++++++++++++++ ...-08-26-subsurface-picture-import-design.md | 30 +- 2 files changed, 2122 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-26-subsurface-picture-import.md diff --git a/docs/superpowers/plans/2026-08-26-subsurface-picture-import.md b/docs/superpowers/plans/2026-08-26-subsurface-picture-import.md new file mode 100644 index 0000000000..8bcda16bb4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-subsurface-picture-import.md @@ -0,0 +1,2094 @@ +# Subsurface Picture Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Import the photos a Subsurface logbook references via `` elements, resolving each foreign absolute path against a user-picked media folder. + +**Architecture:** A parsed `` becomes a payload entry under a new `ImportEntityType.media`. Resolution adds no matching logic: each entry is dressed as a transient unsaved `MediaItem` and fed through the existing media repair ladder (`FolderCandidateSource.harvest`, `detectPrefixMove`, `buildRepairProposals`). A conditional fourth acquisition step collects the folder and shows match counts, and the adapter attaches resolved files to dives through the existing `diveIdByIndex` map. + +**Tech Stack:** Flutter, Dart, Riverpod, Drift, `xml` package, `flutter_test`. + +**Spec:** `docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md` + +## Global Constraints + +- **No em-dashes (U+2014) anywhere**, including code, comments, commit messages, and ARB strings. En-dashes as prose punctuation and " - " as prose punctuation are equally forbidden. Pre-existing em-dashes in files you touch stay as they are; do not add new ones. +- **No emojis** in code, comments, or documentation. +- **Immutability:** never mutate a domain entity or a caller's list in place. The one sanctioned exception is `PayloadMerger`, which already mutates its own freshly-deep-copied maps; follow the file's existing style there. +- **File size:** 200-400 lines typical, 800 maximum. +- **TDD:** the failing test is written and observed failing before the implementation, every time. +- **l10n:** every new user-facing string is added to all 11 ARB files: `en, ar, de, es, fr, he, hu, it, nl, pt, zh`. `zh` plurals use only the `other` branch; every other locale uses `one` and `other`. +- **Run `dart format .`** before each commit. +- **Never pipe `flutter test` into another command**: the pipeline reports the second command's exit status, so a failure reads as a pass. +- **Do not run two `flutter test` invocations concurrently** in this worktree. +- Schema stays at 161. This feature adds no migration. + +## File Structure + +**Created** + +| File | Responsibility | +| --- | --- | +| `lib/features/universal_import/domain/services/import_media_resolver.dart` | Format-agnostic resolution of payload media entries against a folder root. Owns `ImportMediaResolution`. | +| `lib/features/import_wizard/presentation/widgets/photo_folder_step.dart` | The Photos acquisition step widget, desktop picker and mobile notice. | +| `test/features/universal_import/domain/services/import_media_resolver_test.dart` | Resolver tests over a real temp tree. | +| `test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart` | Step widget tests. | + +**Modified** + +| File | Change | +| --- | --- | +| `lib/features/universal_import/data/models/import_enums.dart` | `ImportEntityType.media` plus its two switch arms. | +| `lib/features/import_wizard/domain/models/import_bundle.dart` | `ImportEntityType.media`. | +| `lib/features/universal_import/data/services/payload_merger.dart` | Media appended without folding; `_diveIndex` rebased per file. | +| `lib/features/universal_import/data/parsers/subsurface_xml_parser.dart` | `_collectPictures` on both dive-walk paths. | +| `lib/features/media/data/services/media_import_service.dart` | `importLocalFileForDive` gains `latitude`, `longitude`, `subdirectory`. | +| `lib/features/universal_import/presentation/providers/universal_import_state.dart` | Photo folder root, resolution, skip flag. | +| `lib/features/universal_import/presentation/providers/universal_import_providers.dart` | `pickPhotoFolder`, `skipPhotos` notifier methods. | +| `lib/features/import_wizard/data/adapters/universal_adapter.dart` | Fourth acquisition step, media group in `buildBundle`, commit path. | +| `lib/l10n/arb/app_*.arb` (11 files) | Seven new `importWizard_photos_*` keys. | + +--- + +### Task 1: Payload slot for media + +Adds the enum member to both `ImportEntityType` declarations and teaches `PayloadMerger` to carry media across a multi-file batch. Dart's exhaustive switches will point at every site that must be updated; work through the analyzer until it is clean. + +**Files:** +- Modify: `lib/features/universal_import/data/models/import_enums.dart:261-303` +- Modify: `lib/features/import_wizard/domain/models/import_bundle.dart:25-58` +- Modify: `lib/features/universal_import/data/services/payload_merger.dart:49-108`, `:183-210` +- Test: `test/features/universal_import/data/services/payload_merger_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ImportEntityType.media` in both enums. Payload media entry keys, relied on by every later task: `filename` (`String`, the foreign absolute path), `offsetSeconds` (`int?`), `latitude` (`double?`), `longitude` (`double?`), `_diveIndex` (`int`, index into the payload's dive list). + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/universal_import/data/services/payload_merger_test.dart`, inside the existing top-level `main()`: + +```dart + group('media', () { + test('rebases _diveIndex onto the merged dive list', () { + ImportPayload payloadWith({ + required int diveCount, + required List pictureDiveIndices, + }) { + return ImportPayload( + entities: { + ImportEntityType.dives: [ + for (var i = 0; i < diveCount; i++) + {'uddfId': 'd$i', 'dateTime': DateTime(2025, 1, 1 + i)}, + ], + ImportEntityType.media: [ + for (final index in pictureDiveIndices) + { + 'filename': '/home/jai/Pictures/p$index.jpg', + 'offsetSeconds': 200, + '_diveIndex': index, + }, + ], + }, + ); + } + + final merged = const PayloadMerger().merge([ + FilePayload( + fileId: 'f0', + fileName: 'first.ssrf', + payload: payloadWith(diveCount: 2, pictureDiveIndices: [0, 1]), + ), + FilePayload( + fileId: 'f1', + fileName: 'second.ssrf', + payload: payloadWith(diveCount: 3, pictureDiveIndices: [0, 2]), + ), + ]); + + final dives = merged.entitiesOf(ImportEntityType.dives); + final media = merged.entitiesOf(ImportEntityType.media); + expect(dives, hasLength(5)); + expect(media, hasLength(4)); + // First file's pictures keep their indices; second file's shift by 2. + expect(media.map((m) => m['_diveIndex']), [0, 1, 2, 4]); + }); + + test('never folds two pictures with the same filename', () { + final payload = ImportPayload( + entities: { + ImportEntityType.dives: [ + {'uddfId': 'd0', 'dateTime': DateTime(2025, 1, 1)}, + ], + ImportEntityType.media: [ + {'filename': '/p/same.jpg', '_diveIndex': 0}, + {'filename': '/p/same.jpg', '_diveIndex': 0}, + ], + }, + ); + + final merged = const PayloadMerger().merge([ + FilePayload(fileId: 'f0', fileName: 'a.ssrf', payload: payload), + ]); + + expect(merged.entitiesOf(ImportEntityType.media), hasLength(2)); + }); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/features/universal_import/data/services/payload_merger_test.dart` +Expected: FAIL. `ImportEntityType.media` is not defined, so the file does not compile. + +- [ ] **Step 3: Add the enum member to the parser-facing enum** + +In `lib/features/universal_import/data/models/import_enums.dart`, add `media` as the last member before the `;`, and an arm to each of the two switches: + +```dart +enum ImportEntityType { + dives, + sites, + trips, + equipment, + equipmentSets, + buddies, + diveCenters, + certifications, + courses, + tags, + diveTypes, + serviceRecords, + media; + + String get displayName => switch (this) { + // ... existing arms unchanged ... + serviceRecords => 'Service Records', + media => 'Photos', + }; + + String get shortName => switch (this) { + // ... existing arms unchanged ... + serviceRecords => 'Service', + media => 'Photos', + }; +} +``` + +- [ ] **Step 4: Add the enum member to the wizard-facing enum** + +In `lib/features/import_wizard/domain/models/import_bundle.dart`, append to `ImportEntityType`: + +```dart + /// Courses. + courses, + + /// Photos referenced by an imported logbook. + media, +} +``` + +- [ ] **Step 5: Teach PayloadMerger to carry media** + +In `payload_merger.dart`, inside `merge`, capture the dive offset at the top of the per-input loop and add a media branch alongside the existing dives branch: + +```dart + for (final input in inputs) { + warnings.addAll(input.payload.warnings); + + // Dives are appended without folding, so each file's dive indices shift + // by the number of dives already collected. Captured BEFORE this input's + // dives are added, so media can rebase onto the merged dive list. + final diveOffset = (entities[ImportEntityType.dives] ?? const []).length; + + for (final type in ImportEntityType.values) { + final items = input.payload.entitiesOf(type); + if (items.isEmpty) continue; + + for (final original in items) { + final item = _namespaced(original, input.fileId, type); + item['_sourceFile'] = input.fileName; + // Display names can collide (same basename in different folders); + // the id is the collision-free key for per-file attribution. + item['_sourceFileId'] = input.fileId; + + // Two pictures of the same file are both real, so media never + // folds. Its dive pointer is rebased onto the merged dive list. + if (type == ImportEntityType.media) { + final index = item['_diveIndex']; + if (index is int) item['_diveIndex'] = index + diveOffset; + (entities[type] ??= []).add(item); + continue; + } + + if (type == ImportEntityType.dives) { + (entities[type] ??= []).add(item); + continue; + } +``` + +The rest of the loop body is unchanged. + +- [ ] **Step 6: Add the exhaustive-switch arm in `_foldKey`** + +Media reaches `_foldKey` only if the branch above is ever removed, but the switch must stay exhaustive. Extend the existing null-returning group in `payload_merger.dart:197`: + +```dart + case ImportEntityType.dives: + // Service records are events, not named entities: two services on the + // same item are both real and must never fold together. + case ImportEntityType.serviceRecords: + // Media is handled before this point and has no name to fold on. + case ImportEntityType.media: + return null; +``` + +- [ ] **Step 7: Fix every other exhaustive switch the analyzer reports** + +Run: `flutter analyze lib test` +Expected: a list of non-exhaustive switch errors across the import wizard and parsers. For each, add a `media` arm that matches the neighbouring reference-entity behaviour (media is not a duplicate-checked entity and has no repository, so the correct arm is almost always the same one `serviceRecords` uses). Do not add speculative behaviour: the goal is only to restore exhaustiveness. + +Repeat `flutter analyze lib test` until it reports no issues. Per the project's CI rule, infos count as failures. + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `flutter test test/features/universal_import/data/services/payload_merger_test.dart` +Expected: PASS, both new tests included. + +- [ ] **Step 9: Commit** + +```bash +dart format . +git add -A +git commit -m "feat(import): add a media entity type to the import payload + +Media never folds across files and carries a _diveIndex pointer, which +PayloadMerger rebases onto the merged dive list. + +Refs #1147" +``` + +--- + +### Task 2: Parse `` elements + +**Files:** +- Modify: `lib/features/universal_import/data/parsers/subsurface_xml_parser.dart:95-160` (both dive-walk paths), plus a new private method near `_collectTags` at `:452` +- Test: `test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart` + +**Interfaces:** +- Consumes: `ImportEntityType.media` from Task 1. +- Produces: payload entries under `ImportEntityType.media` with the keys listed in Task 1's Produces block. + +- [ ] **Step 1: Write the failing tests** + +Append to `main()` in `test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart`: + +```dart + group('picture parsing', () { + test('parses filename, offset and gps, pointing at the owning dive', + () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media, hasLength(1)); + expect(media.first['filename'], '/home/jai/Pictures/2025/dive042.jpg'); + expect(media.first['offsetSeconds'], 200); + expect(media.first['latitude'], closeTo(18.465562, 1e-6)); + expect(media.first['longitude'], closeTo(-66.084902, 1e-6)); + expect(media.first['_diveIndex'], 0); + }); + + test('parses a negative offset', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media.single['offsetSeconds'], -65); + }); + + test('keeps a picture whose offset is unparseable, with a null offset', + () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media, hasLength(1)); + expect(media.single['offsetSeconds'], isNull); + }); + + test('keeps a Windows path verbatim for the resolver to normalise', + () async { + final result = await parser.parse( + xmlBytes(r''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media.single['filename'], r'C:\Users\jai\Pictures\dive042.jpg'); + }); + + test('drops a picture with no filename and warns', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + expect(result.entitiesOf(ImportEntityType.media), isEmpty); + expect( + result.warnings.any((w) => w.entityType == ImportEntityType.media), + isTrue, + ); + }); + + test('collects pictures from trip-wrapped dives too, with correct indices', + () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media, hasLength(2)); + // Trip dives are walked first, so the trip picture points at dive 0. + expect( + media.map((m) => [m['filename'], m['_diveIndex']]), + [ + ['/p/trip.jpg', 0], + ['/p/solo.jpg', 1], + ], + ); + }); + + test('omits the media key entirely when a logbook has no pictures', + () async { + final result = await parser.parse( + xmlBytes(''' + + + + + +'''), + ); + + expect(result.entities.containsKey(ImportEntityType.media), isFalse); + }); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart --name "picture parsing"` +Expected: FAIL. Every test reports an empty media list, because nothing parses pictures yet. + +- [ ] **Step 3: Add the picture collector** + +In `subsurface_xml_parser.dart`, add this method immediately after `_collectTags` (which ends at `:469`): + +```dart + /// Collects `` elements from [diveElement] into [allMedia]. + /// + /// Subsurface stores an absolute path from the exporting machine, so + /// `filename` is kept verbatim and resolved later against a user-picked + /// folder. `offset` is signed and relative to dive start; a picture taken + /// before the dive began carries a negative offset. An unparseable offset + /// costs the picture its timestamp, not its import, so it is kept with a + /// null offset. + void _collectPictures( + XmlElement diveElement, + int diveIndex, + List> allMedia, + List warnings, + ) { + for (final picture in diveElement.findElements('picture')) { + final filename = picture.getAttribute('filename')?.trim(); + if (filename == null || filename.isEmpty) { + warnings.add( + const ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'Skipped a photo with no filename', + entityType: ImportEntityType.media, + ), + ); + continue; + } + + final gps = _parseGpsPair(picture.getAttribute('gps')); + allMedia.add({ + 'filename': filename, + 'offsetSeconds': _parseSignedDurationSeconds( + picture.getAttribute('offset'), + ), + 'latitude': gps?.$1, + 'longitude': gps?.$2, + '_diveIndex': diveIndex, + }); + } + } + + /// Parses a signed Subsurface duration: '+3:20 min', '-1:05 min', '3:20 min'. + /// + /// Returns null when the value is absent or malformed. The sign applies to + /// the whole duration, so '-1:05 min' is -65 seconds, not -60 plus 5. + static int? _parseSignedDurationSeconds(String? value) { + if (value == null || value.isEmpty) return null; + final trimmed = value.trim(); + final negative = trimmed.startsWith('-'); + final magnitude = (negative || trimmed.startsWith('+')) + ? trimmed.substring(1) + : trimmed; + final seconds = _parseDurationSeconds(magnitude); + if (seconds == null) return null; + return negative ? -seconds : seconds; + } + + /// Parses a Subsurface `gps` attribute: two space-separated decimal degrees. + static (double, double)? _parseGpsPair(String? value) { + if (value == null || value.isEmpty) return null; + final parts = value.trim().split(RegExp(r'\s+')); + if (parts.length != 2) return null; + final latitude = double.tryParse(parts[0]); + final longitude = double.tryParse(parts[1]); + if (latitude == null || longitude == null) return null; + return (latitude, longitude); + } +``` + +- [ ] **Step 4: Call the collector from both dive-walk paths** + +In `parse`, declare the accumulator next to `allTags` and `allBuddies`: + +```dart + final allTags = >{}; + final allBuddies = >{}; + final allMedia = >[]; +``` + +In the trip-wrapped walk, after `_collectBuddies(...)` and before `dives.add(diveData)`: + +```dart + _collectTags(diveElement, diveData, allTags); + _collectBuddies(diveElement, diveData, allBuddies); + // dives.length is this dive's index, because the picture is + // collected before the dive is appended. + _collectPictures(diveElement, dives.length, allMedia, warnings); + dives.add(diveData); +``` + +In the standalone walk, make the identical insertion: + +```dart + _collectTags(diveElement, diveData, allTags); + _collectBuddies(diveElement, diveData, allBuddies); + _collectPictures(diveElement, dives.length, allMedia, warnings); + dives.add(diveData); +``` + +Then file the results alongside the other entity types, next to the existing `if (allTags.isNotEmpty)` block: + +```dart + if (allMedia.isNotEmpty) entities[ImportEntityType.media] = allMedia; +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `flutter test test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart` +Expected: PASS, including the pre-existing tests in the file. + +- [ ] **Step 6: Commit** + +```bash +dart format . +git add -A +git commit -m "feat(import): parse Subsurface elements + +Collects filename, signed offset and gps from both dive-walk paths, +pointing each picture at its owning dive by index. + +Refs #1147" +``` + +--- + +### Task 3: Import media resolver + +Resolution reuses the media repair ladder wholesale. This task adds the adapter +between payload maps and that ladder, plus one prerequisite fix to the harvest. + +**Prerequisite: the harvest's basename extraction is POSIX-only.** +`folder_candidate_source.dart:41` builds its filename index with +`path.lastIndexOf('/')`. On a Windows host `Directory.list` yields +`C:\Photos\dive042.jpg`, so that search returns -1 and the whole path becomes +the index key. Every filename lookup then misses. This is a pre-existing defect +in the repair feature, not something this feature introduces, but resolution +cannot work on Windows until it is fixed, and Windows is a first-class target +for this feature (many Subsurface users export from it). + +The foreign side of the comparison has the mirror problem, and it is ours to +own: a logbook exported from Windows carries `C:\Users\jai\...` regardless of +which platform imports it, so the resolver must normalise the separators it +feeds the ladder rather than assuming the exporting machine matched this one. + +Steps 1 and 2 below fix the harvest and commit it separately, so a reviewer can +judge that change on its own. + +**Files:** +- Modify: `lib/features/media/data/services/repair/folder_candidate_source.dart:36-48` +- Create: `lib/features/universal_import/domain/services/import_media_resolver.dart` +- Test: `test/features/media/data/services/repair/folder_candidate_source_test.dart` +- Test: `test/features/universal_import/domain/services/import_media_resolver_test.dart` + +**Interfaces:** +- Consumes: payload media entry keys from Task 1. `FolderCandidateSource` (`lib/features/media/data/services/repair/folder_candidate_source.dart:14`), `detectPrefixMove` and `buildRepairProposals` (`lib/features/media/domain/services/media_repair_matcher.dart:8`, `:83`), `RepairConfidence` and `RepairProposal` (`lib/features/media/domain/services/media_repair_types.dart:4`, `:65`). +- Produces: + - `class ImportMediaResolution` with `final Map resolvedPathByIndex`, `final int reRootedCount`, `final int filenameOnlyCount`, `final int notFoundCount`, and `int get matchedCount => resolvedPathByIndex.length`. + - `class ImportMediaResolver` with `Future resolve({required List> media, required String rootPath})`. + +- [ ] **Step 1: Make the harvest's basename extraction platform-correct** + +Add a failing test to +`test/features/media/data/services/repair/folder_candidate_source_test.dart` +that asserts the index is keyed by basename, not by full path: + +```dart + test('indexes candidates by basename, not by full path', () async { + final root = await Directory.systemTemp.createTemp('harvest_basename_'); + addTearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + final nested = Directory(p.join(root.path, 'Trips', 'Bonaire')); + await nested.create(recursive: true); + File(p.join(nested.path, 'dive042.jpg')).writeAsStringSync('bytes'); + + final harvest = + await FolderCandidateSource(roots: [root.path]).harvest(const []); + + expect(harvest.byFilename.keys, contains('dive042.jpg')); + }); +``` + +Run: `flutter test test/features/media/data/services/repair/folder_candidate_source_test.dart` +Expected: PASS on macOS and Linux, because `lastIndexOf('/')` happens to be +right there. The test exists to lock the contract in place before the change +and to fail on Windows, where the current code is broken. + +Then replace the hand-rolled split at `folder_candidate_source.dart:41` with +the platform-aware helper, adding `import 'package:path/path.dart' as p;` to +the file's imports: + +```dart + final path = entity.path; + // p.basename follows the host's separator. A hand-rolled + // lastIndexOf('/') silently indexes the entire path as the key on + // Windows, where Directory.list yields backslash-separated paths. + final name = p.basename(path).toLowerCase(); +``` + +Run the test file again and confirm it still passes. + +- [ ] **Step 2: Commit the harvest fix** + +```bash +dart format . +git add -A +git commit -m "fix(media): key the repair harvest by basename on every platform + +lastIndexOf('/') indexed the whole path as the key on Windows, so every +filename lookup missed. + +Refs #1147" +``` + +- [ ] **Step 3: Write the failing resolver tests** + +Create `test/features/universal_import/domain/services/import_media_resolver_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:submersion/features/universal_import/domain/services/import_media_resolver.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('import_media_resolver_'); + }); + + tearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + + Future writeFile(String relativePath) async { + final file = File(p.join(root.path, relativePath)); + await file.parent.create(recursive: true); + await file.writeAsString('bytes'); + } + + Map picture(String filename, {int index = 0}) => { + 'filename': filename, + 'offsetSeconds': 200, + '_diveIndex': index, + }; + + test('re-roots a whole moved tree', () async { + await writeFile(p.join('2025', 'dive042.jpg')); + await writeFile(p.join('2025', 'dive043.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [ + picture('/home/jai/Pictures/2025/dive042.jpg'), + picture('/home/jai/Pictures/2025/dive043.jpg', index: 1), + ], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 2); + expect(resolution.reRootedCount, 2); + expect(resolution.filenameOnlyCount, 0); + expect(resolution.notFoundCount, 0); + expect( + resolution.resolvedPathByIndex[0], + p.join(root.path, '2025', 'dive042.jpg'), + ); + }); + + test('falls back to a filename match in a reorganised tree', () async { + await writeFile(p.join('Archive', 'Bonaire', 'dive042.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/2025/dive042.jpg')], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 1); + expect(resolution.filenameOnlyCount, 1); + expect(resolution.reRootedCount, 0); + expect( + resolution.resolvedPathByIndex[0], + p.join(root.path, 'Archive', 'Bonaire', 'dive042.jpg'), + ); + }); + + test('reports a picture that is nowhere under the root', () async { + await writeFile(p.join('2025', 'other.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/2025/missing.jpg')], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 0); + expect(resolution.notFoundCount, 1); + expect(resolution.resolvedPathByIndex, isEmpty); + }); + + test('resolves an ambiguous filename to a single candidate', () async { + await writeFile(p.join('a', 'dive042.jpg')); + await writeFile(p.join('b', 'dive042.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/dive042.jpg')], + rootPath: root.path, + ); + + // One picture yields at most one resolved path; which of the two + // candidates wins is not contractual, only that it resolves exactly once + // and is reported as a filename-only match. + expect(resolution.matchedCount, 1); + expect(resolution.filenameOnlyCount, 1); + }); + + test('reports every picture as not found when the root does not exist', + () async { + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/dive042.jpg')], + rootPath: p.join(root.path, 'no-such-folder'), + ); + + expect(resolution.matchedCount, 0); + expect(resolution.notFoundCount, 1); + }); + + test('resolves a path exported from Windows', () async { + await writeFile(p.join('2025', 'dive042.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture(r'C:\Users\jai\Pictures\2025\dive042.jpg')], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 1); + expect( + resolution.resolvedPathByIndex[0], + p.join(root.path, '2025', 'dive042.jpg'), + ); + }); + + test('foreignBasename treats both separators as separators', () { + expect(foreignBasename(r'C:\Users\jai\dive.jpg'), 'dive.jpg'); + expect(foreignBasename('/home/jai/dive.jpg'), 'dive.jpg'); + expect(foreignBasename('dive.jpg'), 'dive.jpg'); + }); + + test('skips a picture whose filename is missing or empty', () async { + final resolution = await const ImportMediaResolver().resolve( + media: [ + {'offsetSeconds': 1, '_diveIndex': 0}, + {'filename': '', '_diveIndex': 1}, + ], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 0); + expect(resolution.notFoundCount, 2); + }); +} +``` + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `flutter test test/features/universal_import/domain/services/import_media_resolver_test.dart` +Expected: FAIL. `import_media_resolver.dart` does not exist, so the file does not compile. + +- [ ] **Step 5: Write the resolver** + +Create `lib/features/universal_import/domain/services/import_media_resolver.dart`: + +```dart +import 'package:submersion/features/media/data/services/repair/folder_candidate_source.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/services/media_repair_matcher.dart'; +import 'package:submersion/features/media/domain/services/media_repair_types.dart'; + +/// The outcome of resolving a payload's media entries against a folder root. +class ImportMediaResolution { + const ImportMediaResolution({ + required this.resolvedPathByIndex, + required this.reRootedCount, + required this.filenameOnlyCount, + required this.notFoundCount, + }); + + const ImportMediaResolution.empty() + : resolvedPathByIndex = const {}, + reRootedCount = 0, + filenameOnlyCount = 0, + notFoundCount = 0; + + /// Local path on this machine, keyed by the picture's index in the payload + /// media list. A picture that resolved to nothing is absent. + final Map resolvedPathByIndex; + + /// Matched by re-rooting the whole moved tree. The strongest signal here: + /// the picture sits at the same relative position it did on the exporting + /// machine. + final int reRootedCount; + + /// Matched on filename alone, somewhere under the root. Weaker: a + /// reorganised library resolves this way, and so does a coincidence. + final int filenameOnlyCount; + + /// Found nowhere under the root. + final int notFoundCount; + + int get matchedCount => resolvedPathByIndex.length; +} + +/// Resolves the foreign absolute paths a logbook references against a folder +/// the user picked on this machine. +/// +/// Deliberately format-agnostic: it knows only the payload media contract +/// (`filename` plus a position in the list), so a UDDF `` parser can +/// feed the same resolver without changing anything here. +/// +/// No matching logic lives in this class. Resolution is the media repair +/// ladder, reached by dressing each picture as a transient unsaved +/// [MediaItem]: harvest the folder into a filename index, detect a wholesale +/// tree move, then run the ladder. Keeping the two features on one matcher +/// means a moved photo library is interpreted the same way whether the user +/// arrives via import or via repair. +class ImportMediaResolver { + const ImportMediaResolver(); + + Future resolve({ + required List> media, + required String rootPath, + }) async { + if (media.isEmpty) return const ImportMediaResolution.empty(); + + // A picture with no usable filename can never resolve, but it still has + // to be counted, so it is excluded from the ladder and added to the + // not-found tally at the end. + final items = {}; + for (var i = 0; i < media.length; i++) { + final filename = (media[i]['filename'] as String?)?.trim(); + if (filename == null || filename.isEmpty) continue; + items[i] = _transientItem(filename); + } + + if (items.isEmpty) { + return ImportMediaResolution( + resolvedPathByIndex: const {}, + reRootedCount: 0, + filenameOnlyCount: 0, + notFoundCount: media.length, + ); + } + + final indices = items.keys.toList(); + final rows = [for (final index in indices) items[index]!]; + + final harvest = await FolderCandidateSource(roots: [rootPath]).harvest(rows); + final prefixMove = detectPrefixMove( + brokenPaths: [for (final row in rows) row.filePath!], + foundPaths: harvest.foundPaths, + ); + final proposals = buildRepairProposals( + brokenRows: rows, + candidatesByFilename: harvest.byFilename, + prefixMove: prefixMove, + foundPaths: harvest.foundPaths, + ); + + final resolved = {}; + var reRooted = 0; + var filenameOnly = 0; + var notFound = media.length - items.length; + + for (var i = 0; i < proposals.length; i++) { + final proposal = proposals[i]; + final path = proposal.candidate?.path; + if (proposal.confidence == RepairConfidence.unmatched || path == null) { + notFound++; + continue; + } + resolved[indices[i]] = path; + if (proposal.viaPrefixMove) { + reRooted++; + } else { + filenameOnly++; + } + } + + return ImportMediaResolution( + resolvedPathByIndex: resolved, + reRootedCount: reRooted, + filenameOnlyCount: filenameOnly, + notFoundCount: notFound, + ); + } + + /// A [MediaItem] that is never persisted. It exists only to satisfy the + /// repair ladder's parameter type; the ladder reads `filePath` and + /// `originalFilename` and nothing else. + /// + /// The path came from another machine, possibly another platform, so both + /// fields are normalised here rather than left for the ladder to guess. + /// `originalFilename` is set explicitly because the ladder prefers it over + /// parsing the path, which spares it the separator question entirely. + static MediaItem _transientItem(String foreignPath) { + final epoch = DateTime.fromMillisecondsSinceEpoch(0); + return MediaItem( + id: '', + mediaType: MediaType.photo, + sourceType: MediaSourceType.localFile, + filePath: foreignPath.replaceAll(r'\', '/'), + originalFilename: foreignBasename(foreignPath), + takenAt: epoch, + createdAt: epoch, + updatedAt: epoch, + ); + } +} + +/// Basename of a path produced by an unknown platform. +/// +/// `p.basename` follows the HOST's separator, which is the wrong question for +/// a path that arrived in a file: a logbook exported from Windows carries +/// `C:\Users\jai\dive.jpg` no matter which platform imports it. Both +/// separators are therefore treated as separators, which is safe in practice +/// because a photo filename containing a literal backslash is vanishingly rare +/// next to the certainty of Windows-exported logbooks. +@visibleForTesting +String foreignBasename(String path) { + final index = path.lastIndexOf(RegExp(r'[/\\]')); + return index < 0 ? path : path.substring(index + 1); +} +``` + +Add `import 'package:flutter/foundation.dart' show visibleForTesting;` for the +annotation. + +If `MediaItem`'s constructor rejects any of these arguments, read +`lib/features/media/domain/entities/media_item.dart:111-160` and supply exactly +the required parameters. Do not add fields the ladder does not read. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flutter test test/features/universal_import/domain/services/import_media_resolver_test.dart` +Expected: PASS, all seven tests. + +If the re-root test resolves as a filename-only match instead, check that +`detectPrefixMove` received at least two broken paths: it deliberately returns +null below two, because one coincidental filename is not evidence of a move. + +- [ ] **Step 7: Commit** + +```bash +dart format . +git add -A +git commit -m "feat(import): resolve referenced photos against a picked folder + +Dresses each payload media entry as a transient MediaItem and runs it +through the existing media repair ladder, so import and repair read a +moved photo library the same way. + +Refs #1147" +``` + +--- + +### Task 4: Let the media writer carry coordinates and a destination + +`importLocalFileForDive` is currently shaped for the OCR scan flow that +introduced it: it always writes into `scanned_logs/` and never sets +coordinates. Widen it without disturbing that caller. + +**Files:** +- Modify: `lib/features/media/data/services/media_import_service.dart:70-100` +- Test: `test/features/media/data/services/media_import_service_test.dart` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `importLocalFileForDive({required File sourceFile, required String diveId, DateTime? takenAt, double? latitude, double? longitude, String subdirectory = 'scanned_logs'})`. + +- [ ] **Step 1: Write the failing test** + +Append to `main()` in `test/features/media/data/services/media_import_service_test.dart`, following the file's existing setup for building a service with a fake repository and a temp documents directory: + +```dart + group('importLocalFileForDive coordinates and destination', () { + test('stores coordinates and writes into the requested subdirectory', + () async { + final source = File(p.join(tempDir.path, 'photo.jpg')) + ..writeAsStringSync('bytes'); + + final created = await service.importLocalFileForDive( + sourceFile: source, + diveId: 'dive-1', + takenAt: DateTime.utc(2025, 1, 15, 10, 3, 20), + latitude: 18.465562, + longitude: -66.084902, + subdirectory: 'imported_photos', + ); + + expect(created.latitude, closeTo(18.465562, 1e-6)); + expect(created.longitude, closeTo(-66.084902, 1e-6)); + expect(created.takenAt, DateTime.utc(2025, 1, 15, 10, 3, 20)); + expect(p.basename(p.dirname(created.filePath!)), 'imported_photos'); + }); + + test('defaults to scanned_logs with no coordinates', () async { + final source = File(p.join(tempDir.path, 'scan.jpg')) + ..writeAsStringSync('bytes'); + + final created = await service.importLocalFileForDive( + sourceFile: source, + diveId: 'dive-1', + ); + + expect(created.latitude, isNull); + expect(created.longitude, isNull); + expect(p.basename(p.dirname(created.filePath!)), 'scanned_logs'); + }); + }); +``` + +If the test file has no shared `service` and `tempDir`, build them inside the +group exactly as the file's existing groups do. Do not introduce a second +fake repository style. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/media/data/services/media_import_service_test.dart --name "coordinates and destination"` +Expected: FAIL. `latitude`, `longitude` and `subdirectory` are not named parameters of `importLocalFileForDive`. + +- [ ] **Step 3: Widen the method** + +In `media_import_service.dart`, replace the signature and the two lines that +depend on it: + +```dart + /// Copies [sourceFile] into the app documents directory (subdir + /// [subdirectory]) and creates a localFile media row linked to [diveId]. + /// + /// [subdirectory] defaults to 'scanned_logs' for the OCR scan flow that + /// introduced this method; file imports pass their own so an imported + /// logbook's photos are not filed as scanned pages. + /// + /// [latitude] and [longitude] are the photo's own coordinates when the + /// source recorded them, which is not the same as the dive site's. + Future importLocalFileForDive({ + required File sourceFile, + required String diveId, + DateTime? takenAt, + double? latitude, + double? longitude, + String subdirectory = 'scanned_logs', + }) async { + final docs = await _documentsDirectory(); + final dir = Directory(p.join(docs.path, subdirectory)); + await dir.create(recursive: true); + final sourceExt = p.extension(sourceFile.path); + final ext = sourceExt.isEmpty ? '.jpg' : sourceExt; + final destName = '${DateTime.now().millisecondsSinceEpoch}$ext'; + final dest = await sourceFile.copy(p.join(dir.path, destName)); + final now = DateTime.now(); + final item = MediaItem( + id: '', + diveId: diveId, + mediaType: MediaType.photo, + sourceType: MediaSourceType.localFile, + filePath: dest.path, + originalFilename: p.basename(sourceFile.path), + latitude: latitude, + longitude: longitude, + takenAt: takenAt ?? now, + createdAt: now, + updatedAt: now, + ); + final created = await _mediaRepository.createMedia(item); + onMediaCreated?.call(created.id); + return created; + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/media/data/services/media_import_service_test.dart` +Expected: PASS, including the pre-existing tests. + +- [ ] **Step 5: Check the destination-name collision guard** + +`destName` is a millisecond timestamp. Two photos copied inside the same +millisecond would collide and the second `copy` would overwrite the first. +Confirm whether the existing code already guards this. If it does not, add a +counter suffix mirroring `zip_expansion_service.dart:210`: + +```dart + var destName = '${DateTime.now().millisecondsSinceEpoch}$ext'; + var destPath = p.join(dir.path, destName); + var counter = 1; + while (File(destPath).existsSync()) { + destName = '${DateTime.now().millisecondsSinceEpoch}_${counter++}$ext'; + destPath = p.join(dir.path, destName); + } + final dest = await sourceFile.copy(destPath); +``` + +Add a test that imports two files back to back and asserts two distinct +`filePath` values. + +- [ ] **Step 6: Commit** + +```bash +dart format . +git add -A +git commit -m "feat(media): let importLocalFileForDive carry coordinates and a destination + +The OCR caller keeps its scanned_logs default; file imports pass their +own subdirectory and the photo's own coordinates. + +Refs #1147" +``` + +--- + +### Task 5: Wizard state for the photo folder + +**Files:** +- Modify: `lib/features/universal_import/presentation/providers/universal_import_state.dart:64-170` +- Modify: `lib/features/universal_import/presentation/providers/universal_import_providers.dart` +- Test: `test/features/universal_import/presentation/providers/universal_import_notifier_test.dart` + +**Interfaces:** +- Consumes: `ImportMediaResolver` and `ImportMediaResolution` from Task 3, `ImportEntityType.media` from Task 1. +- Produces: + - `UniversalImportState` fields `final String? photoFolderPath`, `final ImportMediaResolution? photoResolution`, `final bool photosSkipped`, all threaded through `copyWith`. + - Notifier methods `Future resolvePhotosIn(String rootPath)` and `void skipPhotos()`. + - `final universalAdapterPhotosReadyProvider` and `final universalAdapterNoPhotosProvider`, both `Provider`. + +- [ ] **Step 1: Write the failing test** + +Append to the notifier test file: + +```dart + group('photo folder resolution', () { + test('resolvePhotosIn stores the root and the resolution', () async { + final root = await Directory.systemTemp.createTemp('wizard_photos_'); + addTearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + final photo = File(p.join(root.path, 'dive042.jpg')) + ..writeAsStringSync('bytes'); + + final container = buildContainer(); + addTearDown(container.dispose); + final notifier = container.read( + universalImportNotifierProvider.notifier, + ); + + notifier.debugSetPayload( + ImportPayload( + entities: { + ImportEntityType.dives: [ + {'uddfId': 'd0', 'dateTime': DateTime(2025, 1, 15)}, + ], + ImportEntityType.media: [ + { + 'filename': '/home/jai/Pictures/dive042.jpg', + '_diveIndex': 0, + }, + ], + }, + ), + ); + + await notifier.resolvePhotosIn(root.path); + + final state = container.read(universalImportNotifierProvider); + expect(state.photoFolderPath, root.path); + expect(state.photoResolution?.matchedCount, 1); + expect(state.photoResolution?.resolvedPathByIndex[0], photo.path); + expect(state.photosSkipped, isFalse); + }); + + test('skipPhotos clears any resolution and marks the step done', () async { + final container = buildContainer(); + addTearDown(container.dispose); + final notifier = container.read( + universalImportNotifierProvider.notifier, + ); + + notifier.skipPhotos(); + + final state = container.read(universalImportNotifierProvider); + expect(state.photosSkipped, isTrue); + expect(state.photoResolution, isNull); + expect(container.read(universalAdapterPhotosReadyProvider), isTrue); + }); + + test('the step is ready with no pictures and not ready with unhandled ones', + () async { + final container = buildContainer(); + addTearDown(container.dispose); + final notifier = container.read( + universalImportNotifierProvider.notifier, + ); + + notifier.debugSetPayload( + const ImportPayload(entities: {}), + ); + expect(container.read(universalAdapterNoPhotosProvider), isTrue); + expect(container.read(universalAdapterPhotosReadyProvider), isTrue); + + notifier.debugSetPayload( + ImportPayload( + entities: { + ImportEntityType.media: [ + {'filename': '/p/a.jpg', '_diveIndex': 0}, + ], + }, + ), + ); + expect(container.read(universalAdapterNoPhotosProvider), isFalse); + expect(container.read(universalAdapterPhotosReadyProvider), isFalse); + }); + }); +``` + +If the test file has no `buildContainer` helper or no way to seed a payload, +follow whatever seam the file's existing tests use. If it needs a new test-only +seam, add `@visibleForTesting void debugSetPayload(ImportPayload payload)` to +the notifier rather than reaching into private state. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/universal_import/presentation/providers/universal_import_notifier_test.dart --name "photo folder resolution"` +Expected: FAIL to compile: `photoFolderPath`, `resolvePhotosIn`, `skipPhotos` and the two providers do not exist. + +- [ ] **Step 3: Add the state fields** + +In `universal_import_state.dart`, add three fields near `photoPathsByBaseName` +at `:77`, with the same documentation density as its neighbours: + +```dart + /// Folder the user picked to resolve a logbook's referenced photos against. + /// Null until the Photos step runs, and on mobile where it cannot be picked. + final String? photoFolderPath; + + /// Outcome of resolving the payload's media entries against + /// [photoFolderPath]. Null when no folder has been picked. + final ImportMediaResolution? photoResolution; + + /// True once the user has explicitly chosen to import without photos. + /// Distinct from a null [photoResolution], which only means undecided. + final bool photosSkipped; +``` + +Add them to the constructor with `this.photosSkipped = false`, and thread all +three through `copyWith`. Both nullable fields must be clearable, so each gets +a `clearX` flag exactly as `clearPayload` does at +`universal_import_state.dart:187`. The exact `copyWith` body lines are given in +Step 4. + +- [ ] **Step 4: Add the notifier methods and providers** + +In `universal_import_providers.dart`: + +```dart + /// Resolves the payload's referenced photos against [rootPath]. + /// + /// Never throws into the wizard: a scan failure resolves to zero matches and + /// the user can pick a different folder or skip. Photos must not be able to + /// block a dive import. + Future resolvePhotosIn(String rootPath) async { + final media = state.payload?.entitiesOf(ImportEntityType.media); + if (media == null || media.isEmpty) return; + + state = state.copyWith(photoFolderPath: rootPath, isLoading: true); + ImportMediaResolution resolution; + try { + resolution = await const ImportMediaResolver().resolve( + media: media, + rootPath: rootPath, + ); + } catch (e) { + _log.warning('Photo resolution failed under $rootPath: $e'); + resolution = ImportMediaResolution( + resolvedPathByIndex: const {}, + reRootedCount: 0, + filenameOnlyCount: 0, + notFoundCount: media.length, + ); + } + state = state.copyWith( + photoResolution: resolution, + photosSkipped: false, + isLoading: false, + ); + } + + /// Proceeds without photos. + void skipPhotos() { + state = state.copyWith( + photosSkipped: true, + clearPhotoResolution: true, + clearPhotoFolderPath: true, + ); + } +``` + +`clearPhotoResolution` and `clearPhotoFolderPath` follow the file's existing +`clearX` flag convention (`clearPayload` at +`universal_import_state.dart:187`, applied at `:238` as +`payload: clearPayload ? null : (payload ?? this.payload)`). Add both flags to +the `copyWith` parameter list and apply them the same way: + +```dart + photoFolderPath: clearPhotoFolderPath + ? null + : (photoFolderPath ?? this.photoFolderPath), + photoResolution: clearPhotoResolution + ? null + : (photoResolution ?? this.photoResolution), + photosSkipped: photosSkipped ?? this.photosSkipped, +``` + +Add a `LoggerService` field if the notifier does not already have one, matching +its neighbours. + +Then add the two providers next to `universalAdapterMappingReadyProvider`: + +```dart +/// True when the parsed payload references no photos at all. Used as the +/// Photos step's auto-advance condition, so the step is invisible for every +/// import that has nothing to resolve. +final universalAdapterNoPhotosProvider = Provider((ref) { + final payload = ref.watch( + universalImportNotifierProvider.select((s) => s.payload), + ); + return (payload?.entitiesOf(ImportEntityType.media) ?? const []).isEmpty; +}); + +/// True when the Photos step has nothing left to ask. Deliberately looser +/// than [universalAdapterNoPhotosProvider]: a user who picked a folder or +/// chose to skip may advance, but the step is never auto-advanced past a +/// decision they have not made. +final universalAdapterPhotosReadyProvider = Provider((ref) { + if (ref.watch(universalAdapterNoPhotosProvider)) return true; + final state = ref.watch(universalImportNotifierProvider); + return state.photosSkipped || state.photoResolution != null; +}); +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `flutter test test/features/universal_import/presentation/providers/universal_import_notifier_test.dart` +Expected: PASS, including the pre-existing tests. + +- [ ] **Step 6: Commit** + +```bash +dart format . +git add -A +git commit -m "feat(import): hold the picked photo folder and its resolution in wizard state + +Refs #1147" +``` + +--- + +### Task 6: The Photos step widget + +**Files:** +- Create: `lib/features/import_wizard/presentation/widgets/photo_folder_step.dart` +- Modify: `lib/l10n/arb/app_en.arb` and the other 10 ARB files +- Test: `test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart` + +**Interfaces:** +- Consumes: `universalAdapterNoPhotosProvider`, `universalImportNotifierProvider`, `resolvePhotosIn`, `skipPhotos` from Task 5. +- Produces: `class PhotoFolderStep extends ConsumerWidget` with `const PhotoFolderStep({super.key, this.pickFolderOverride})` where `pickFolderOverride` is `Future Function()?`, injected so widget tests never open a native picker. `media_sources_section_view.dart:64` already uses exactly this seam; follow it. + +- [ ] **Step 1: Add the English strings** + +Add to `lib/l10n/arb/app_en.arb`, keeping the file's alphabetical-ish grouping +with the other `importWizard_` keys: + +```json + "importWizard_photos_stepLabel": "Photos", + "@importWizard_photos_stepLabel": { + "description": "Wizard step label for resolving photos referenced by an imported logbook" + }, + "importWizard_photos_foundCount": "{count, plural, one{1 photo referenced in this logbook} other{{count} photos referenced in this logbook}}", + "@importWizard_photos_foundCount": { + "description": "Count of photos the imported logbook refers to", + "placeholders": {"count": {"type": "int"}} + }, + "importWizard_photos_chooseFolder": "Choose photo folder...", + "@importWizard_photos_chooseFolder": { + "description": "Button that opens a folder picker for locating referenced photos" + }, + "importWizard_photos_scanning": "Scanning folder...", + "@importWizard_photos_scanning": { + "description": "Progress label while the picked folder is being scanned" + }, + "importWizard_photos_matchSummary": "{matched} matched, {byName} by filename only, {missing} not found", + "@importWizard_photos_matchSummary": { + "description": "Result of resolving referenced photos against the picked folder", + "placeholders": {"matched": {"type": "int"}, "byName": {"type": "int"}, "missing": {"type": "int"}} + }, + "importWizard_photos_skip": "Skip photos", + "@importWizard_photos_skip": { + "description": "Button to continue the import without photos" + }, + "importWizard_photos_mobileUnsupported": "Importing photos needs a folder on this device's disk. Run this import on a computer to include them. Dives and sites import normally.", + "@importWizard_photos_mobileUnsupported": { + "description": "Shown on mobile, where a photo folder cannot be picked" + }, +``` + +- [ ] **Step 2: Add the same keys to the other 10 ARB files** + +Add the value lines (no `@` metadata blocks: those live only in `app_en.arb`) +to each of `app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, +`app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb`. + +`de`: +```json + "importWizard_photos_stepLabel": "Fotos", + "importWizard_photos_foundCount": "{count, plural, one{1 Foto in diesem Logbuch referenziert} other{{count} Fotos in diesem Logbuch referenziert}}", + "importWizard_photos_chooseFolder": "Fotoordner wählen...", + "importWizard_photos_scanning": "Ordner wird durchsucht...", + "importWizard_photos_matchSummary": "{matched} zugeordnet, {byName} nur über den Dateinamen, {missing} nicht gefunden", + "importWizard_photos_skip": "Fotos überspringen", + "importWizard_photos_mobileUnsupported": "Für den Fotoimport wird ein Ordner auf dem Speicher dieses Geräts benötigt. Führe diesen Import an einem Computer aus, um Fotos einzuschließen. Tauchgänge und Tauchplätze werden normal importiert.", +``` + +`es`: +```json + "importWizard_photos_stepLabel": "Fotos", + "importWizard_photos_foundCount": "{count, plural, one{1 foto referenciada en este cuaderno} other{{count} fotos referenciadas en este cuaderno}}", + "importWizard_photos_chooseFolder": "Elegir carpeta de fotos...", + "importWizard_photos_scanning": "Explorando la carpeta...", + "importWizard_photos_matchSummary": "{matched} coincidencias, {byName} solo por nombre de archivo, {missing} no encontradas", + "importWizard_photos_skip": "Omitir fotos", + "importWizard_photos_mobileUnsupported": "Importar fotos requiere una carpeta en el disco de este dispositivo. Ejecuta esta importación en un ordenador para incluirlas. Las inmersiones y los puntos de buceo se importan con normalidad.", +``` + +`fr`: +```json + "importWizard_photos_stepLabel": "Photos", + "importWizard_photos_foundCount": "{count, plural, one{1 photo référencée dans ce carnet} other{{count} photos référencées dans ce carnet}}", + "importWizard_photos_chooseFolder": "Choisir un dossier de photos...", + "importWizard_photos_scanning": "Analyse du dossier...", + "importWizard_photos_matchSummary": "{matched} associées, {byName} par nom de fichier uniquement, {missing} introuvables", + "importWizard_photos_skip": "Ignorer les photos", + "importWizard_photos_mobileUnsupported": "L'import de photos nécessite un dossier sur le disque de cet appareil. Lancez cet import sur un ordinateur pour les inclure. Les plongées et les sites s'importent normalement.", +``` + +`it`: +```json + "importWizard_photos_stepLabel": "Foto", + "importWizard_photos_foundCount": "{count, plural, one{1 foto referenziata in questo diario} other{{count} foto referenziate in questo diario}}", + "importWizard_photos_chooseFolder": "Scegli la cartella delle foto...", + "importWizard_photos_scanning": "Scansione della cartella...", + "importWizard_photos_matchSummary": "{matched} associate, {byName} solo per nome file, {missing} non trovate", + "importWizard_photos_skip": "Salta le foto", + "importWizard_photos_mobileUnsupported": "L'importazione delle foto richiede una cartella sul disco di questo dispositivo. Esegui questa importazione su un computer per includerle. Immersioni e siti vengono importati normalmente.", +``` + +`nl`: +```json + "importWizard_photos_stepLabel": "Foto's", + "importWizard_photos_foundCount": "{count, plural, one{1 foto waarnaar dit logboek verwijst} other{{count} foto's waarnaar dit logboek verwijst}}", + "importWizard_photos_chooseFolder": "Fotomap kiezen...", + "importWizard_photos_scanning": "Map wordt gescand...", + "importWizard_photos_matchSummary": "{matched} gekoppeld, {byName} alleen op bestandsnaam, {missing} niet gevonden", + "importWizard_photos_skip": "Foto's overslaan", + "importWizard_photos_mobileUnsupported": "Voor het importeren van foto's is een map op de schijf van dit apparaat nodig. Voer deze import uit op een computer om ze mee te nemen. Duiken en duikstekken worden normaal geïmporteerd.", +``` + +`pt`: +```json + "importWizard_photos_stepLabel": "Fotos", + "importWizard_photos_foundCount": "{count, plural, one{1 foto referenciada neste diário} other{{count} fotos referenciadas neste diário}}", + "importWizard_photos_chooseFolder": "Escolher pasta de fotos...", + "importWizard_photos_scanning": "A analisar a pasta...", + "importWizard_photos_matchSummary": "{matched} correspondidas, {byName} apenas pelo nome do ficheiro, {missing} não encontradas", + "importWizard_photos_skip": "Ignorar fotos", + "importWizard_photos_mobileUnsupported": "Importar fotos requer uma pasta no disco deste dispositivo. Execute esta importação num computador para as incluir. Os mergulhos e locais são importados normalmente.", +``` + +`hu`: +```json + "importWizard_photos_stepLabel": "Fényképek", + "importWizard_photos_foundCount": "{count, plural, one{1 fénykép szerepel ebben a naplóban} other{{count} fénykép szerepel ebben a naplóban}}", + "importWizard_photos_chooseFolder": "Fényképmappa kiválasztása...", + "importWizard_photos_scanning": "Mappa vizsgálata...", + "importWizard_photos_matchSummary": "{matched} párosítva, {byName} csak fájlnév alapján, {missing} nem található", + "importWizard_photos_skip": "Fényképek kihagyása", + "importWizard_photos_mobileUnsupported": "A fényképek importálásához az eszköz lemezén lévő mappa szükséges. Futtasd ezt az importálást számítógépen, hogy a fényképek is bekerüljenek. A merülések és a merülőhelyek normálisan importálódnak.", +``` + +`ar`: +```json + "importWizard_photos_stepLabel": "الصور", + "importWizard_photos_foundCount": "{count, plural, one{صورة واحدة مشار إليها في هذا السجل} other{{count} صور مشار إليها في هذا السجل}}", + "importWizard_photos_chooseFolder": "اختر مجلد الصور...", + "importWizard_photos_scanning": "جارٍ فحص المجلد...", + "importWizard_photos_matchSummary": "{matched} مطابقة، {byName} بالاسم فقط، {missing} غير موجودة", + "importWizard_photos_skip": "تخطي الصور", + "importWizard_photos_mobileUnsupported": "يتطلب استيراد الصور مجلدًا على قرص هذا الجهاز. شغّل هذا الاستيراد على جهاز كمبيوتر لتضمينها. تُستورد الغطسات والمواقع بشكل طبيعي.", +``` + +`he`: +```json + "importWizard_photos_stepLabel": "תמונות", + "importWizard_photos_foundCount": "{count, plural, one{תמונה אחת מוזכרת ביומן הזה} other{{count} תמונות מוזכרות ביומן הזה}}", + "importWizard_photos_chooseFolder": "בחר תיקיית תמונות...", + "importWizard_photos_scanning": "סורק את התיקייה...", + "importWizard_photos_matchSummary": "{matched} הותאמו, {byName} לפי שם קובץ בלבד, {missing} לא נמצאו", + "importWizard_photos_skip": "דלג על התמונות", + "importWizard_photos_mobileUnsupported": "ייבוא תמונות מחייב תיקייה בדיסק של המכשיר הזה. הרץ את הייבוא במחשב כדי לכלול אותן. צלילות ואתרים מיובאים כרגיל.", +``` + +`zh` (plural uses only the `other` branch, matching the file's convention): +```json + "importWizard_photos_stepLabel": "照片", + "importWizard_photos_foundCount": "{count, plural, other{此日志引用了 {count} 张照片}}", + "importWizard_photos_chooseFolder": "选择照片文件夹...", + "importWizard_photos_scanning": "正在扫描文件夹...", + "importWizard_photos_matchSummary": "已匹配 {matched} 张,仅按文件名匹配 {byName} 张,未找到 {missing} 张", + "importWizard_photos_skip": "跳过照片", + "importWizard_photos_mobileUnsupported": "导入照片需要此设备磁盘上的文件夹。请在电脑上运行此导入以包含照片。潜水记录和潜点会正常导入。", +``` + +- [ ] **Step 3: Regenerate localizations and verify** + +Run: `flutter gen-l10n` +Then: `flutter analyze lib` +Expected: no issues. CI regenerates l10n but never verifies it, so a +generation failure caught here is one that would otherwise reach main. + +- [ ] **Step 4: Write the failing widget tests** + +Create `test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart`: + +```dart +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:submersion/features/import_wizard/presentation/widgets/photo_folder_step.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/presentation/providers/universal_import_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + Widget host(Widget child, {List overrides = const []}) { + return ProviderScope( + overrides: overrides, + child: MaterialApp( + // Pin the locale: an unpinned host adopts the test device locale and + // the string assertions below stop matching. + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), + ); + } + + testWidgets('shows the referenced photo count and a folder button', + (tester) async { + final root = await Directory.systemTemp.createTemp('photo_step_'); + addTearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + File(p.join(root.path, 'dive042.jpg')).writeAsStringSync('bytes'); + + await tester.pumpWidget( + host( + PhotoFolderStep(pickFolderOverride: () async => root.path), + overrides: [/* seed a payload carrying one picture */], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('1 photo referenced in this logbook'), findsOneWidget); + expect(find.text('Choose photo folder...'), findsOneWidget); + + await tester.tap(find.text('Choose photo folder...')); + await tester.pumpAndSettle(); + + expect(find.textContaining('1 matched'), findsOneWidget); + }); + + testWidgets('offers to skip photos', (tester) async { + await tester.pumpWidget( + host( + const PhotoFolderStep(), + overrides: [/* seed a payload carrying one picture */], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Skip photos'), findsOneWidget); + }); + + testWidgets('explains the limitation instead of picking on mobile', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + await tester.pumpWidget( + host( + const PhotoFolderStep(), + overrides: [/* seed a payload carrying one picture */], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Choose photo folder...'), findsNothing); + expect( + find.textContaining('Run this import on a computer'), + findsOneWidget, + ); + }); +} +``` + +Replace each `/* seed a payload carrying one picture */` comment with the +override that seeds this payload, using whichever seam the wizard's existing +widget tests use for the same job: + +```dart +ImportPayload( + entities: { + ImportEntityType.dives: [ + {'uddfId': 'd0', 'dateTime': DateTime(2025, 1, 15)}, + ], + ImportEntityType.media: [ + {'filename': '/home/jai/Pictures/dive042.jpg', '_diveIndex': 0}, + ], + }, +) +``` + +- [ ] **Step 5: Run the tests to verify they fail** + +Run: `flutter test test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart` +Expected: FAIL. `photo_folder_step.dart` does not exist. + +- [ ] **Step 6: Write the widget** + +Create `lib/features/import_wizard/presentation/widgets/photo_folder_step.dart`. +It is a `ConsumerWidget` with these rules: + +- Read the payload's media count. Render + `context.l10n.importWizard_photos_foundCount(count)`. +- On a desktop platform (`Platform.isMacOS || Platform.isWindows || Platform.isLinux`, + guarded so it is testable via `defaultTargetPlatform`), render a button + labelled `importWizard_photos_chooseFolder` that calls + `pickFolderOverride?.call() ?? FilePicker.getDirectoryPath()` and, on a + non-null result, `resolvePhotosIn(path)`. +- While `isLoading`, render `importWizard_photos_scanning` with a progress + indicator. +- Once `photoResolution` is non-null, render + `importWizard_photos_matchSummary(matched, byName, missing)` using + `matchedCount`, `filenameOnlyCount` and `notFoundCount`. Show the picked + folder path underneath. +- On a non-desktop platform, render `importWizard_photos_mobileUnsupported` + and no picker. +- Always render a `importWizard_photos_skip` action that calls `skipPhotos()`. + +Follow `media_sources_section_view.dart:60-80` for the picker seam and +`media_repair_wizard_page.dart` for the pane layout idiom. Keep the file under +200 lines; if it grows past that, the summary block is the natural private +widget to extract. + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `flutter test test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart` +Expected: PASS, all three tests. + +- [ ] **Step 8: Commit** + +```bash +dart format . +git add -A +git commit -m "feat(import): add the Photos step for locating referenced photos + +Desktop picks a folder and sees the match counts; mobile is told plainly +that photo import needs a computer rather than silently receiving a +subset. + +Refs #1147" +``` + +--- + +### Task 7: Wire the step into the wizard and attach the photos + +The last task connects the pieces: register the step, surface the media group +in Review, and write the resolved files at commit. + +**Files:** +- Modify: `lib/features/import_wizard/data/adapters/universal_adapter.dart:176-207` (step registration), `:224-299` (`buildBundle`), `:578-596` (commit), `:870-935` (`attachImportedPhotos` neighbourhood) +- Test: `test/features/import_wizard/data/adapters/universal_adapter_test.dart` + +**Interfaces:** +- Consumes: everything from Tasks 1 through 6. +- Produces: no new public API. `UnifiedImportResult.attachedPhotoCount` and `.unmatchedPhotoCount` (already declared at `unified_import_result.dart:34` and `:39`) gain a second producer. + +- [ ] **Step 1: Write the failing test** + +Append to `test/features/import_wizard/data/adapters/universal_adapter_test.dart`: + +```dart + group('resolved photo attachment', () { + test('attaches each resolved photo to its own dive', () async { + final attached = <({String path, String diveId, DateTime? takenAt})>[]; + + final count = await UniversalImportAdapter.attachResolvedPhotos( + media: [ + { + 'filename': '/home/jai/Pictures/a.jpg', + 'offsetSeconds': 200, + '_diveIndex': 0, + }, + { + 'filename': '/home/jai/Pictures/b.jpg', + 'offsetSeconds': null, + '_diveIndex': 1, + }, + ], + resolvedPathByIndex: const { + 0: '/Users/eric/Photos/a.jpg', + 1: '/Users/eric/Photos/b.jpg', + }, + diveIdByIndex: const {0: 'dive-a', 1: 'dive-b'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + {'dateTime': DateTime.utc(2025, 1, 16, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + attached.add((path: file.path, diveId: diveId, takenAt: takenAt)); + }, + ); + + expect(count, 2); + expect(attached[0].diveId, 'dive-a'); + // Dive start plus the 3:20 offset. + expect(attached[0].takenAt, DateTime.utc(2025, 1, 15, 10, 3, 20)); + expect(attached[1].diveId, 'dive-b'); + // No offset: falls back to the dive's own start. + expect(attached[1].takenAt, DateTime.utc(2025, 1, 16, 10)); + }); + + test('drops photos whose dive was folded away by consolidation', () async { + var attachCalls = 0; + + final count = await UniversalImportAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + ], + resolvedPathByIndex: const {0: '/Users/eric/Photos/a.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {'dive-a'}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + attachCalls++; + }, + ); + + expect(count, 0); + expect(attachCalls, 0); + }); + + test('counts a failed copy without failing the import', () async { + final count = await UniversalImportAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + {'filename': '/p/b.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + ], + resolvedPathByIndex: const {0: '/x/a.jpg', 1: '/x/b.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + if (file.path.endsWith('b.jpg')) { + throw const FileSystemException('copy failed'); + } + }, + ); + + expect(count, 1); + }); + + test('passes the picture coordinates through', () async { + double? seenLatitude; + + await UniversalImportAdapter.attachResolvedPhotos( + media: [ + { + 'filename': '/p/a.jpg', + 'offsetSeconds': 0, + 'latitude': 18.465562, + 'longitude': -66.084902, + '_diveIndex': 0, + }, + ], + resolvedPathByIndex: const {0: '/x/a.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + seenLatitude = latitude; + }, + ); + + expect(seenLatitude, closeTo(18.465562, 1e-6)); + }); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `flutter test test/features/import_wizard/data/adapters/universal_adapter_test.dart --name "resolved photo attachment"` +Expected: FAIL. `attachResolvedPhotos` is not defined. + +- [ ] **Step 3: Write the attach helper** + +In `universal_adapter.dart`, add this static method next to +`attachImportedPhotos` at `:879`. It is deliberately static and pure so it can +be tested without a container, exactly as its neighbour is: + +```dart + /// Attaches resolved photos to the dives that survived import. + /// + /// Each payload media entry names its dive by `_diveIndex`, so unlike + /// [attachImportedPhotos] this needs no one-dive-per-file rule: a + /// multi-dive logbook attaches each photo to exactly the dive that + /// referenced it. + /// + /// A copy failure is counted and skipped rather than thrown: the dive + /// import has already succeeded and must not be undone by a photo. Unlike + /// [attachImportedPhotos], the failure is not silent, because the caller + /// reports the shortfall against the resolved count. + /// + /// Returns the number of photos actually attached. + static Future attachResolvedPhotos({ + required List> media, + required Map resolvedPathByIndex, + required Map diveIdByIndex, + required Set removedDiveIds, + required List> dives, + required Future Function( + File file, + String diveId, + DateTime? takenAt, + double? latitude, + double? longitude, + ) attach, + }) async { + var attachedCount = 0; + + for (final entry in resolvedPathByIndex.entries) { + final mediaIndex = entry.key; + if (mediaIndex < 0 || mediaIndex >= media.length) continue; + final picture = media[mediaIndex]; + + final diveIndex = picture['_diveIndex']; + if (diveIndex is! int) continue; + final diveId = diveIdByIndex[diveIndex]; + if (diveId == null || removedDiveIds.contains(diveId)) continue; + + DateTime? takenAt; + if (diveIndex >= 0 && diveIndex < dives.length) { + final start = dives[diveIndex]['dateTime'] as DateTime?; + final offsetSeconds = picture['offsetSeconds']; + takenAt = start == null + ? null + : (offsetSeconds is int + ? start.add(Duration(seconds: offsetSeconds)) + : start); + } + + try { + await attach( + File(entry.value), + diveId, + takenAt, + asDoubleOrNull(picture['latitude']), + asDoubleOrNull(picture['longitude']), + ); + attachedCount++; + } catch (e) { + _log.warning('Failed to attach imported photo ${entry.value}: $e'); + } + } + + return attachedCount; + } +``` + +If the file has no `_log`, use whatever logging idiom the adapter already uses. +`asDoubleOrNull` is already imported in this file (it is used by +`_diveToEntityItem` at `:689`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `flutter test test/features/import_wizard/data/adapters/universal_adapter_test.dart --name "resolved photo attachment"` +Expected: PASS, all four tests. + +- [ ] **Step 5: Register the Photos step** + +In `acquisitionSteps` at `:176`, append a fourth step after Map Fields: + +```dart + WizardStepDef( + label: 'Photos', + icon: Icons.photo_library_outlined, + builder: (context) => const PhotoFolderStep(), + canAdvance: universalAdapterPhotosReadyProvider, + // Stricter than canAdvance on purpose: the step auto-skips only when + // the logbook references no photos at all, never past a decision the + // user has not made. + canAutoAdvance: universalAdapterNoPhotosProvider, + autoAdvance: true, + ), +``` + +- [ ] **Step 6: Surface the media group in Review** + +In `buildBundle` at `:285`, after the courses group: + +```dart + _addGroupIfNotEmpty( + groups, + wizard.ImportEntityType.media, + payload.entitiesOf(ui.ImportEntityType.media), + _mediaToEntityItem, + ); +``` + +And add the converter next to `_courseToEntityItem` at `:860`: + +```dart + EntityItem _mediaToEntityItem(Map data) { + final filename = (data['filename'] as String?) ?? ''; + final base = filename.isEmpty ? 'Unnamed' : p.basename(filename); + return EntityItem(title: base, subtitle: filename); + } +``` + +- [ ] **Step 7: Call the attach helper at commit** + +In the commit path at `:578`, immediately after the existing +`attachImportedPhotos` call, add the resolved-photo attachment. Both can run: +they cover different sources and neither double-counts, because ZIP sidecars +and `` references never describe the same file. + +```dart + // Attach photos the logbook referenced by absolute path, resolved + // against the folder the user picked in the Photos step. + final resolution = notifierState.photoResolution; + final resolvedPhotos = resolution == null + ? 0 + : await attachResolvedPhotos( + media: payload.entitiesOf(ui.ImportEntityType.media), + resolvedPathByIndex: resolution.resolvedPathByIndex, + diveIdByIndex: result.diveIdByIndex, + removedDiveIds: removedDiveIds, + dives: payload.entitiesOf(ui.ImportEntityType.dives), + attach: (file, diveId, takenAt, latitude, longitude) async { + await _ref + .read(mediaImportServiceProvider) + .importLocalFileForDive( + sourceFile: file, + diveId: diveId, + takenAt: takenAt, + latitude: latitude, + longitude: longitude, + subdirectory: 'imported_photos', + ); + }, + ); +``` + +Then fold the counts into the result the method already builds, adding to the +existing `attachedPhotoCount` and `unmatchedPhotoCount` rather than replacing +them: + +```dart + attachedPhotoCount: attachedPhotos + resolvedPhotos, + unmatchedPhotoCount: + notifierState.unmatchedPhotoCount + (resolution?.notFoundCount ?? 0), +``` + +Locate the existing `UnifiedImportResult(...)` construction in this method and +adjust those two arguments in place. + +- [ ] **Step 8: Verify the whole feature analyzes and the suite passes** + +Run: `flutter analyze lib test` +Expected: no issues, infos included. + +Run: `flutter test` +Expected: PASS. + +If a single file fails here but passes when run alone, that is a known +cross-test interference pattern in this repo, not a defect in this change. +Re-run the lone file to confirm before investigating. + +- [ ] **Step 9: Commit** + +```bash +dart format . +git add -A +git commit -m "feat(import): attach Subsurface-referenced photos to their dives + +Registers the Photos step, shows the photos in review, and writes each +resolved file against the dive that referenced it, carrying the +picture's own offset and coordinates. + +Closes #1147" +``` + +--- + +## Manual verification + +After Task 7, before opening the PR, confirm the feature on a real desktop run. +Automated tests cover the units; this checks the seams they cannot. + +1. Export a small logbook from Subsurface with photos attached to at least two + dives, so `detectPrefixMove` has the two paths it needs to fire. +2. Copy the photo folder to a different location than the export references. +3. Import the `.ssrf`. Confirm the Photos step appears, that picking the copied + folder reports the expected match counts, and that the summary reports the + attached total. +4. Open one of the imported dives and confirm the photo is attached with a + sensible capture time. +5. Import a Subsurface file with no `` elements and confirm the Photos + step never appears. diff --git a/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md b/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md index db901f5ee1..0394db256a 100644 --- a/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md +++ b/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md @@ -81,11 +81,37 @@ step between Review and Import is not expressible without changing the wizard shell. `universal_adapter.dart:176` currently declares three acquisition steps: Select File, Confirm Source, Map Fields. -**F8. The offset attribute has no native column yet.** +**F8. The repair ladder assumes POSIX separators on both sides.** +`folder_candidate_source.dart:41` and `media_repair_matcher.dart:154` both find +a basename with `path.lastIndexOf('/')`, and `detectPrefixMove` splits on `'/'` +(`:18`, `:22`). On a Windows host `Directory.list` yields backslash-separated +paths, so the harvest indexes whole paths as keys and every lookup misses. That +is a pre-existing defect in the repair feature. It becomes this feature's +problem twice over, because a logbook exported from Windows carries +`C:\Users\jai\...` on to whatever platform imports it. + +**F9. The offset attribute has no native column yet.** `database.dart:3168` pins `currentSchemaVersion = 161`, and `grep -rn "manualElapsed"` over `lib/` returns nothing. The `media.manual_elapsed_seconds` column (v162) that models exactly this quantity is still in the open PR #1287. +### D9. Separator handling + +Two fixes, on opposite sides of the comparison (F8). + +The harvest is fixed in place: `FolderCandidateSource` switches to +`p.basename`, which follows the host separator. This repairs the media repair +feature on Windows as a side effect, which is the right outcome; the two +features share the ladder precisely so they cannot disagree. + +The foreign side is normalised by the resolver, because only the resolver knows +its input came from another machine. `ImportMediaResolver` converts backslashes +to forward slashes before handing a path to `detectPrefixMove`, and sets +`originalFilename` explicitly to a both-separator basename so the ladder never +has to parse the foreign path at all. Treating `\` as a separator on POSIX is +technically lossy, since a POSIX filename may legally contain one; that risk is +accepted against the certainty of Windows-exported logbooks. + ## Design ### D1. Reuse the repair ladder rather than writing a resolver @@ -197,7 +223,7 @@ today. `takenAt` is the dive's `dateTime` plus `offsetSeconds` when both are known, and the dive's `dateTime` otherwise. `offsetSeconds` is retained on the payload map even though only `takenAt` -consumes it now. When #1287 lands `media.manual_elapsed_seconds` (F8), adopting +consumes it now. When #1287 lands `media.manual_elapsed_seconds` (F9), adopting it is a single additional field on the write, with no rework of the parser or resolver. From 553d4ddf8b518add3bedc5cf6a559b7fa60468cf Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:24:09 -0400 Subject: [PATCH 075/122] feat(import): add a media entity type to the import payload Media never folds across files and carries a _diveIndex pointer, which PayloadMerger rebases onto the merged dive list. Refs #1147 --- .../services/import_provider_invalidator.dart | 7 + .../domain/models/import_bundle.dart | 3 + .../pages/unified_import_wizard.dart | 6 + .../widgets/import_summary_step.dart | 4 + .../presentation/widgets/review_step.dart | 2 + .../data/models/import_enums.dart | 611 +++++++++--------- .../data/services/payload_merger.dart | 16 + .../widgets/import_summary_step.dart | 353 +++++----- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 + lib/l10n/arb/app_localizations_ar.dart | 3 + lib/l10n/arb/app_localizations_de.dart | 3 + lib/l10n/arb/app_localizations_en.dart | 3 + lib/l10n/arb/app_localizations_es.dart | 3 + lib/l10n/arb/app_localizations_fr.dart | 3 + lib/l10n/arb/app_localizations_he.dart | 3 + lib/l10n/arb/app_localizations_hu.dart | 3 + lib/l10n/arb/app_localizations_it.dart | 3 + lib/l10n/arb/app_localizations_nl.dart | 3 + lib/l10n/arb/app_localizations_pt.dart | 3 + lib/l10n/arb/app_localizations_zh.dart | 3 + lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + .../import_provider_invalidator_test.dart | 7 + .../data/services/payload_merger_test.dart | 66 ++ 33 files changed, 648 insertions(+), 480 deletions(-) diff --git a/lib/features/import_wizard/data/services/import_provider_invalidator.dart b/lib/features/import_wizard/data/services/import_provider_invalidator.dart index fbfb4f018c..a5935c4128 100644 --- a/lib/features/import_wizard/data/services/import_provider_invalidator.dart +++ b/lib/features/import_wizard/data/services/import_provider_invalidator.dart @@ -13,6 +13,7 @@ import 'package:submersion/features/equipment/presentation/providers/equipment_s import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; /// Invalidates the Riverpod providers that correspond to the given set of /// imported entity types. @@ -74,6 +75,12 @@ void invalidateImportRelatedProviders( case ImportEntityType.diveTypes: ref.invalidate(diveTypesProvider); + + case ImportEntityType.media: + // Photos land on dives that may already be on screen. + ref.invalidate(mediaForDiveProvider); + ref.invalidate(mediaCountForDiveProvider); + ref.invalidate(mediaListNotifierProvider); } } } diff --git a/lib/features/import_wizard/domain/models/import_bundle.dart b/lib/features/import_wizard/domain/models/import_bundle.dart index 0a580982dc..6e0fe1bfad 100644 --- a/lib/features/import_wizard/domain/models/import_bundle.dart +++ b/lib/features/import_wizard/domain/models/import_bundle.dart @@ -55,6 +55,9 @@ enum ImportEntityType { /// Courses. courses, + + /// Photos referenced by an imported logbook. + media, } /// Metadata about the source of an [ImportBundle]. diff --git a/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart b/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart index 57ed3053d4..9e44ac7f30 100644 --- a/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart +++ b/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart @@ -18,6 +18,7 @@ import 'package:submersion/features/import_wizard/data/adapters/dive_computer_ad import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/domain/services/step_skip_calculator.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; @@ -289,6 +290,11 @@ class _UnifiedImportWizardBodyState ref.invalidate(tagsProvider); case ImportEntityType.diveTypes: ref.invalidate(diveTypesProvider); + case ImportEntityType.media: + // Photos land on dives that may already be on screen. + ref.invalidate(mediaForDiveProvider); + ref.invalidate(mediaCountForDiveProvider); + ref.invalidate(mediaListNotifierProvider); } } } diff --git a/lib/features/import_wizard/presentation/widgets/import_summary_step.dart b/lib/features/import_wizard/presentation/widgets/import_summary_step.dart index d09d89528a..f5dc0cd46c 100644 --- a/lib/features/import_wizard/presentation/widgets/import_summary_step.dart +++ b/lib/features/import_wizard/presentation/widgets/import_summary_step.dart @@ -309,6 +309,8 @@ class _SuccessView extends StatelessWidget { return Icons.inventory; case ImportEntityType.courses: return Icons.school; + case ImportEntityType.media: + return Icons.photo_library; } } @@ -336,6 +338,8 @@ class _SuccessView extends StatelessWidget { return l10n.diveImport_uddf_equipmentSets; case ImportEntityType.courses: return l10n.diveImport_uddf_tabCourses; + case ImportEntityType.media: + return l10n.diveImport_uddf_media; } } } diff --git a/lib/features/import_wizard/presentation/widgets/review_step.dart b/lib/features/import_wizard/presentation/widgets/review_step.dart index 08434fe596..1f9be221aa 100644 --- a/lib/features/import_wizard/presentation/widgets/review_step.dart +++ b/lib/features/import_wizard/presentation/widgets/review_step.dart @@ -290,6 +290,8 @@ class _MultiTypeLayoutState extends State<_MultiTypeLayout> { return l10n.diveImport_uddf_equipmentSets; case ImportEntityType.courses: return l10n.diveImport_uddf_tabCourses; + case ImportEntityType.media: + return l10n.diveImport_uddf_media; } } } diff --git a/lib/features/universal_import/data/models/import_enums.dart b/lib/features/universal_import/data/models/import_enums.dart index 2604c22706..75f3bd3173 100644 --- a/lib/features/universal_import/data/models/import_enums.dart +++ b/lib/features/universal_import/data/models/import_enums.dart @@ -1,304 +1,307 @@ -/// File format types that can be detected by the universal import wizard. -enum ImportFormat { - csv, - uddf, - macdiveXml, - macdiveSqlite, - subsurfaceXml, - divingLogXml, - suuntoSml, - suuntoDm5, - fit, - shearwaterDb, - scubapro, - danDl7, - ratioXml, - sqlite, - unknown; - - String get displayName => switch (this) { - csv => 'CSV', - uddf => 'UDDF', - macdiveXml => 'MacDive XML', - macdiveSqlite => 'MacDive SQLite', - subsurfaceXml => 'Subsurface XML', - divingLogXml => 'Diving Log XML', - suuntoSml => 'Suunto SML', - suuntoDm5 => 'Suunto DM5', - fit => 'Garmin FIT', - shearwaterDb => 'Shearwater Cloud', - scubapro => 'Scubapro', - danDl7 => 'DAN DL7', - ratioXml => 'Ratio XML', - sqlite => 'SQLite Database', - unknown => 'Unknown', - }; - - /// Whether this format has a parser implemented in v1.5. - bool get isSupported => switch (this) { - csv || - uddf || - subsurfaceXml || - fit || - shearwaterDb || - macdiveXml || - macdiveSqlite || - danDl7 || - ratioXml => true, - _ => false, - }; -} - -/// Source applications that export dive data. -enum SourceApp { - submersion, - subsurface, - macdive, - divingLog, - diveMate, - shearwater, - suunto, - garminConnect, - scubapro, - ssiMyDiveGuide, - dan, - diverLog, - ratio, - generic; - - String get displayName => switch (this) { - submersion => 'Submersion', - subsurface => 'Subsurface', - macdive => 'MacDive', - divingLog => 'Diving Log', - diveMate => 'DiveMate', - shearwater => 'Shearwater', - suunto => 'Suunto', - garminConnect => 'Garmin Connect', - scubapro => 'Scubapro', - ssiMyDiveGuide => 'SSI MyDiveGuide', - dan => 'DAN', - diverLog => 'DiverLog+', - ratio => 'Ratio Computers', - generic => 'Unknown App', - }; - - /// Instructions for exporting from this app in a supported format. - String? get exportInstructions => switch (this) { - shearwater => null, // Native .db import supported - ratio => null, // Native XML import supported - suunto => - 'In Suunto DM5, select your dives and go to File > Export > UDDF.', - scubapro => - 'In Scubapro LogTRAK, select your dives and export as UDDF format.', - ssiMyDiveGuide => - 'In the SSI app, go to My Logbook and export your dives as CSV.', - dan => - 'Export your dives as DAN DL7 (.zxu) files and import them directly ' - 'into Submersion.', - diverLog => - 'In DiverLog+, sync your dives to DiveCloud. Then sign in at ' - 'divecloud.net in a browser, select your dives, and choose Export ' - 'to download a ZIP of DL7 (.zxu) files with photos. Import that ' - 'ZIP directly into Submersion. Desktop DiverLog Full can also ' - 'export .zxu files via Export Dive Data.', - _ => null, - }; -} - -/// A valid (source app, format) combination for the source override dropdown. -/// -/// Each entry represents a specific import pathway that the system supports, -/// pairing an application with the file format it produces. -class SourceOverrideOption { - final SourceApp sourceApp; - final ImportFormat format; - final String displayName; - - const SourceOverrideOption({ - required this.sourceApp, - required this.format, - required this.displayName, - }); - - /// All supported (app, format) combinations for the override dropdown. - static const List supported = [ - SourceOverrideOption( - sourceApp: SourceApp.submersion, - format: ImportFormat.csv, - displayName: 'Submersion (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.submersion, - format: ImportFormat.uddf, - displayName: 'Submersion (UDDF)', - ), - SourceOverrideOption( - sourceApp: SourceApp.subsurface, - format: ImportFormat.csv, - displayName: 'Subsurface (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.subsurface, - format: ImportFormat.subsurfaceXml, - displayName: 'Subsurface (XML)', - ), - SourceOverrideOption( - sourceApp: SourceApp.macdive, - format: ImportFormat.csv, - displayName: 'MacDive (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.macdive, - format: ImportFormat.macdiveXml, - displayName: 'MacDive (XML)', - ), - SourceOverrideOption( - sourceApp: SourceApp.macdive, - format: ImportFormat.macdiveSqlite, - displayName: 'MacDive (SQLite)', - ), - SourceOverrideOption( - sourceApp: SourceApp.divingLog, - format: ImportFormat.csv, - displayName: 'Diving Log (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.diveMate, - format: ImportFormat.csv, - displayName: 'DiveMate (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.shearwater, - format: ImportFormat.csv, - displayName: 'Shearwater (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.shearwater, - format: ImportFormat.shearwaterDb, - displayName: 'Shearwater (Cloud DB)', - ), - SourceOverrideOption( - sourceApp: SourceApp.garminConnect, - format: ImportFormat.csv, - displayName: 'Garmin Connect (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.garminConnect, - format: ImportFormat.fit, - displayName: 'Garmin Connect (FIT)', - ), - SourceOverrideOption( - sourceApp: SourceApp.suunto, - format: ImportFormat.uddf, - displayName: 'Suunto (UDDF)', - ), - SourceOverrideOption( - sourceApp: SourceApp.ssiMyDiveGuide, - format: ImportFormat.csv, - displayName: 'SSI MyDiveGuide (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.scubapro, - format: ImportFormat.uddf, - displayName: 'Scubapro (UDDF)', - ), - SourceOverrideOption( - sourceApp: SourceApp.diverLog, - format: ImportFormat.danDl7, - displayName: 'DiverLog+ (DL7)', - ), - SourceOverrideOption( - sourceApp: SourceApp.dan, - format: ImportFormat.danDl7, - displayName: 'DAN (DL7)', - ), - SourceOverrideOption( - sourceApp: SourceApp.ratio, - format: ImportFormat.ratioXml, - displayName: 'Ratio Computers (XML)', - ), - ]; - - /// Find the matching option for a given app and format pair, or null. - /// - /// When [format] is null (e.g. state from the old SourceApp-only override), - /// returns the first option matching [sourceApp] so the UI still shows a - /// selection. - static SourceOverrideOption? findMatch( - SourceApp? sourceApp, - ImportFormat? format, - ) { - if (sourceApp == null) return null; - for (final option in supported) { - if (option.sourceApp == sourceApp && option.format == format) { - return option; - } - } - // Fallback: match by sourceApp only when format is unknown. - if (format == null) { - for (final option in supported) { - if (option.sourceApp == sourceApp) return option; - } - } - return null; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SourceOverrideOption && - other.sourceApp == sourceApp && - other.format == format; - - @override - int get hashCode => Object.hash(sourceApp, format); -} - -/// Entity types that can be included in an import payload. -/// -/// Mirrors the existing `UddfEntityType` but used across all import formats. -enum ImportEntityType { - dives, - sites, - trips, - equipment, - equipmentSets, - buddies, - diveCenters, - certifications, - courses, - tags, - diveTypes, - serviceRecords; - - String get displayName => switch (this) { - dives => 'Dives', - sites => 'Sites', - trips => 'Trips', - equipment => 'Equipment', - equipmentSets => 'Equipment Sets', - buddies => 'Buddies', - diveCenters => 'Dive Centers', - certifications => 'Certifications', - courses => 'Courses', - tags => 'Tags', - diveTypes => 'Dive Types', - serviceRecords => 'Service Records', - }; - - String get shortName => switch (this) { - dives => 'Dives', - sites => 'Sites', - trips => 'Trips', - equipment => 'Equipment', - equipmentSets => 'Sets', - buddies => 'Buddies', - diveCenters => 'Centers', - certifications => 'Certs', - courses => 'Courses', - tags => 'Tags', - diveTypes => 'Types', - serviceRecords => 'Service', - }; -} +/// File format types that can be detected by the universal import wizard. +enum ImportFormat { + csv, + uddf, + macdiveXml, + macdiveSqlite, + subsurfaceXml, + divingLogXml, + suuntoSml, + suuntoDm5, + fit, + shearwaterDb, + scubapro, + danDl7, + ratioXml, + sqlite, + unknown; + + String get displayName => switch (this) { + csv => 'CSV', + uddf => 'UDDF', + macdiveXml => 'MacDive XML', + macdiveSqlite => 'MacDive SQLite', + subsurfaceXml => 'Subsurface XML', + divingLogXml => 'Diving Log XML', + suuntoSml => 'Suunto SML', + suuntoDm5 => 'Suunto DM5', + fit => 'Garmin FIT', + shearwaterDb => 'Shearwater Cloud', + scubapro => 'Scubapro', + danDl7 => 'DAN DL7', + ratioXml => 'Ratio XML', + sqlite => 'SQLite Database', + unknown => 'Unknown', + }; + + /// Whether this format has a parser implemented in v1.5. + bool get isSupported => switch (this) { + csv || + uddf || + subsurfaceXml || + fit || + shearwaterDb || + macdiveXml || + macdiveSqlite || + danDl7 || + ratioXml => true, + _ => false, + }; +} + +/// Source applications that export dive data. +enum SourceApp { + submersion, + subsurface, + macdive, + divingLog, + diveMate, + shearwater, + suunto, + garminConnect, + scubapro, + ssiMyDiveGuide, + dan, + diverLog, + ratio, + generic; + + String get displayName => switch (this) { + submersion => 'Submersion', + subsurface => 'Subsurface', + macdive => 'MacDive', + divingLog => 'Diving Log', + diveMate => 'DiveMate', + shearwater => 'Shearwater', + suunto => 'Suunto', + garminConnect => 'Garmin Connect', + scubapro => 'Scubapro', + ssiMyDiveGuide => 'SSI MyDiveGuide', + dan => 'DAN', + diverLog => 'DiverLog+', + ratio => 'Ratio Computers', + generic => 'Unknown App', + }; + + /// Instructions for exporting from this app in a supported format. + String? get exportInstructions => switch (this) { + shearwater => null, // Native .db import supported + ratio => null, // Native XML import supported + suunto => + 'In Suunto DM5, select your dives and go to File > Export > UDDF.', + scubapro => + 'In Scubapro LogTRAK, select your dives and export as UDDF format.', + ssiMyDiveGuide => + 'In the SSI app, go to My Logbook and export your dives as CSV.', + dan => + 'Export your dives as DAN DL7 (.zxu) files and import them directly ' + 'into Submersion.', + diverLog => + 'In DiverLog+, sync your dives to DiveCloud. Then sign in at ' + 'divecloud.net in a browser, select your dives, and choose Export ' + 'to download a ZIP of DL7 (.zxu) files with photos. Import that ' + 'ZIP directly into Submersion. Desktop DiverLog Full can also ' + 'export .zxu files via Export Dive Data.', + _ => null, + }; +} + +/// A valid (source app, format) combination for the source override dropdown. +/// +/// Each entry represents a specific import pathway that the system supports, +/// pairing an application with the file format it produces. +class SourceOverrideOption { + final SourceApp sourceApp; + final ImportFormat format; + final String displayName; + + const SourceOverrideOption({ + required this.sourceApp, + required this.format, + required this.displayName, + }); + + /// All supported (app, format) combinations for the override dropdown. + static const List supported = [ + SourceOverrideOption( + sourceApp: SourceApp.submersion, + format: ImportFormat.csv, + displayName: 'Submersion (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.submersion, + format: ImportFormat.uddf, + displayName: 'Submersion (UDDF)', + ), + SourceOverrideOption( + sourceApp: SourceApp.subsurface, + format: ImportFormat.csv, + displayName: 'Subsurface (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.subsurface, + format: ImportFormat.subsurfaceXml, + displayName: 'Subsurface (XML)', + ), + SourceOverrideOption( + sourceApp: SourceApp.macdive, + format: ImportFormat.csv, + displayName: 'MacDive (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.macdive, + format: ImportFormat.macdiveXml, + displayName: 'MacDive (XML)', + ), + SourceOverrideOption( + sourceApp: SourceApp.macdive, + format: ImportFormat.macdiveSqlite, + displayName: 'MacDive (SQLite)', + ), + SourceOverrideOption( + sourceApp: SourceApp.divingLog, + format: ImportFormat.csv, + displayName: 'Diving Log (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.diveMate, + format: ImportFormat.csv, + displayName: 'DiveMate (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.shearwater, + format: ImportFormat.csv, + displayName: 'Shearwater (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + displayName: 'Shearwater (Cloud DB)', + ), + SourceOverrideOption( + sourceApp: SourceApp.garminConnect, + format: ImportFormat.csv, + displayName: 'Garmin Connect (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.garminConnect, + format: ImportFormat.fit, + displayName: 'Garmin Connect (FIT)', + ), + SourceOverrideOption( + sourceApp: SourceApp.suunto, + format: ImportFormat.uddf, + displayName: 'Suunto (UDDF)', + ), + SourceOverrideOption( + sourceApp: SourceApp.ssiMyDiveGuide, + format: ImportFormat.csv, + displayName: 'SSI MyDiveGuide (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.scubapro, + format: ImportFormat.uddf, + displayName: 'Scubapro (UDDF)', + ), + SourceOverrideOption( + sourceApp: SourceApp.diverLog, + format: ImportFormat.danDl7, + displayName: 'DiverLog+ (DL7)', + ), + SourceOverrideOption( + sourceApp: SourceApp.dan, + format: ImportFormat.danDl7, + displayName: 'DAN (DL7)', + ), + SourceOverrideOption( + sourceApp: SourceApp.ratio, + format: ImportFormat.ratioXml, + displayName: 'Ratio Computers (XML)', + ), + ]; + + /// Find the matching option for a given app and format pair, or null. + /// + /// When [format] is null (e.g. state from the old SourceApp-only override), + /// returns the first option matching [sourceApp] so the UI still shows a + /// selection. + static SourceOverrideOption? findMatch( + SourceApp? sourceApp, + ImportFormat? format, + ) { + if (sourceApp == null) return null; + for (final option in supported) { + if (option.sourceApp == sourceApp && option.format == format) { + return option; + } + } + // Fallback: match by sourceApp only when format is unknown. + if (format == null) { + for (final option in supported) { + if (option.sourceApp == sourceApp) return option; + } + } + return null; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SourceOverrideOption && + other.sourceApp == sourceApp && + other.format == format; + + @override + int get hashCode => Object.hash(sourceApp, format); +} + +/// Entity types that can be included in an import payload. +/// +/// Mirrors the existing `UddfEntityType` but used across all import formats. +enum ImportEntityType { + dives, + sites, + trips, + equipment, + equipmentSets, + buddies, + diveCenters, + certifications, + courses, + tags, + diveTypes, + serviceRecords, + media; + + String get displayName => switch (this) { + dives => 'Dives', + sites => 'Sites', + trips => 'Trips', + equipment => 'Equipment', + equipmentSets => 'Equipment Sets', + buddies => 'Buddies', + diveCenters => 'Dive Centers', + certifications => 'Certifications', + courses => 'Courses', + tags => 'Tags', + diveTypes => 'Dive Types', + serviceRecords => 'Service Records', + media => 'Photos', + }; + + String get shortName => switch (this) { + dives => 'Dives', + sites => 'Sites', + trips => 'Trips', + equipment => 'Equipment', + equipmentSets => 'Sets', + buddies => 'Buddies', + diveCenters => 'Centers', + certifications => 'Certs', + courses => 'Courses', + tags => 'Tags', + diveTypes => 'Types', + serviceRecords => 'Service', + media => 'Photos', + }; +} diff --git a/lib/features/universal_import/data/services/payload_merger.dart b/lib/features/universal_import/data/services/payload_merger.dart index 02e048c2b4..78d8ce89bd 100644 --- a/lib/features/universal_import/data/services/payload_merger.dart +++ b/lib/features/universal_import/data/services/payload_merger.dart @@ -57,6 +57,11 @@ class PayloadMerger { for (final input in inputs) { warnings.addAll(input.payload.warnings); + // Dives are appended without folding, so each file's dive indices shift + // by the number of dives already collected. Captured BEFORE this input's + // dives are added, so media can rebase onto the merged dive list. + final diveOffset = (entities[ImportEntityType.dives] ?? const []).length; + for (final type in ImportEntityType.values) { final items = input.payload.entitiesOf(type); if (items.isEmpty) continue; @@ -68,6 +73,15 @@ class PayloadMerger { // the id is the collision-free key for per-file attribution. item['_sourceFileId'] = input.fileId; + // Two pictures of the same file are both real, so media never + // folds. Its dive pointer is rebased onto the merged dive list. + if (type == ImportEntityType.media) { + final index = item['_diveIndex']; + if (index is int) item['_diveIndex'] = index + diveOffset; + (entities[type] ??= []).add(item); + continue; + } + if (type == ImportEntityType.dives) { (entities[type] ??= []).add(item); continue; @@ -198,6 +212,8 @@ class PayloadMerger { // Service records are events, not named entities: two services on the // same item are both real and must never fold together. case ImportEntityType.serviceRecords: + // Media is handled before this point and has no name to fold on. + case ImportEntityType.media: return null; case ImportEntityType.sites: case ImportEntityType.trips: diff --git a/lib/features/universal_import/presentation/widgets/import_summary_step.dart b/lib/features/universal_import/presentation/widgets/import_summary_step.dart index 76810fb320..0830eb1d1b 100644 --- a/lib/features/universal_import/presentation/widgets/import_summary_step.dart +++ b/lib/features/universal_import/presentation/widgets/import_summary_step.dart @@ -1,176 +1,177 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; -import 'package:submersion/features/universal_import/data/models/import_enums.dart'; -import 'package:submersion/features/universal_import/data/models/import_warning.dart'; -import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; - -/// Step 5: Import summary with counts per entity type. -class ImportSummaryStep extends ConsumerWidget { - const ImportSummaryStep({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(universalImportNotifierProvider); - final theme = Theme.of(context); - final warnings = state.payload?.warnings ?? const []; - - return Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ExcludeSemantics( - child: Icon( - Icons.check_circle, - size: 80, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(height: 24), - Text( - context.l10n.universalImport_label_importComplete, - style: theme.textTheme.headlineMedium, - ), - const SizedBox(height: 16), - for (final entry in state.importCounts.entries) - _SummaryRow( - label: entry.key.displayName, - value: entry.value.toString(), - icon: _iconFor(entry.key), - color: theme.colorScheme.primary, - ), - if (warnings.isNotEmpty) ...[ - const SizedBox(height: 24), - _WarningsSection(warnings: warnings), - ], - const SizedBox(height: 32), - FilledButton( - onPressed: () { - ref.read(universalImportNotifierProvider.notifier).reset(); - context.pop(); - }, - child: Text(context.l10n.universalImport_action_done), - ), - ], - ), - ); - } - - static IconData _iconFor(ImportEntityType type) { - return switch (type) { - ImportEntityType.dives => Icons.scuba_diving, - ImportEntityType.sites => Icons.location_on_outlined, - ImportEntityType.trips => Icons.card_travel, - ImportEntityType.equipment => Icons.build_outlined, - ImportEntityType.equipmentSets => Icons.inventory_2_outlined, - ImportEntityType.buddies => Icons.person_outline, - ImportEntityType.diveCenters => Icons.store_outlined, - ImportEntityType.certifications => Icons.workspace_premium_outlined, - ImportEntityType.courses => Icons.school_outlined, - ImportEntityType.tags => Icons.label_outline, - ImportEntityType.diveTypes => Icons.category_outlined, - ImportEntityType.serviceRecords => Icons.handyman_outlined, - }; - } -} - -class _WarningsSection extends StatelessWidget { - const _WarningsSection({required this.warnings}); - - final List warnings; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Card( - color: theme.colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final w in warnings) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - _iconFor(w.severity), - color: _colorFor(w.severity, theme), - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text(w.message, style: theme.textTheme.bodyMedium), - ), - ], - ), - if (w != warnings.last) const SizedBox(height: 12), - ], - ], - ), - ), - ), - ); - } - - static IconData _iconFor(ImportWarningSeverity s) => switch (s) { - ImportWarningSeverity.info => Icons.info_outline, - ImportWarningSeverity.warning => Icons.warning_amber_rounded, - ImportWarningSeverity.error => Icons.error_outline, - }; - - static Color _colorFor(ImportWarningSeverity s, ThemeData theme) => - switch (s) { - ImportWarningSeverity.info => theme.colorScheme.primary, - ImportWarningSeverity.warning => theme.colorScheme.tertiary, - ImportWarningSeverity.error => theme.colorScheme.error, - }; -} - -class _SummaryRow extends StatelessWidget { - const _SummaryRow({ - required this.label, - required this.value, - required this.icon, - required this.color, - }); - - final String label; - final String value; - final IconData icon; - final Color color; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(width: 8), - Text( - label, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - Text( - value, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; + +/// Step 5: Import summary with counts per entity type. +class ImportSummaryStep extends ConsumerWidget { + const ImportSummaryStep({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(universalImportNotifierProvider); + final theme = Theme.of(context); + final warnings = state.payload?.warnings ?? const []; + + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ExcludeSemantics( + child: Icon( + Icons.check_circle, + size: 80, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 24), + Text( + context.l10n.universalImport_label_importComplete, + style: theme.textTheme.headlineMedium, + ), + const SizedBox(height: 16), + for (final entry in state.importCounts.entries) + _SummaryRow( + label: entry.key.displayName, + value: entry.value.toString(), + icon: _iconFor(entry.key), + color: theme.colorScheme.primary, + ), + if (warnings.isNotEmpty) ...[ + const SizedBox(height: 24), + _WarningsSection(warnings: warnings), + ], + const SizedBox(height: 32), + FilledButton( + onPressed: () { + ref.read(universalImportNotifierProvider.notifier).reset(); + context.pop(); + }, + child: Text(context.l10n.universalImport_action_done), + ), + ], + ), + ); + } + + static IconData _iconFor(ImportEntityType type) { + return switch (type) { + ImportEntityType.dives => Icons.scuba_diving, + ImportEntityType.sites => Icons.location_on_outlined, + ImportEntityType.trips => Icons.card_travel, + ImportEntityType.equipment => Icons.build_outlined, + ImportEntityType.equipmentSets => Icons.inventory_2_outlined, + ImportEntityType.buddies => Icons.person_outline, + ImportEntityType.diveCenters => Icons.store_outlined, + ImportEntityType.certifications => Icons.workspace_premium_outlined, + ImportEntityType.courses => Icons.school_outlined, + ImportEntityType.tags => Icons.label_outline, + ImportEntityType.diveTypes => Icons.category_outlined, + ImportEntityType.serviceRecords => Icons.handyman_outlined, + ImportEntityType.media => Icons.photo_library_outlined, + }; + } +} + +class _WarningsSection extends StatelessWidget { + const _WarningsSection({required this.warnings}); + + final List warnings; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Card( + color: theme.colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final w in warnings) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _iconFor(w.severity), + color: _colorFor(w.severity, theme), + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text(w.message, style: theme.textTheme.bodyMedium), + ), + ], + ), + if (w != warnings.last) const SizedBox(height: 12), + ], + ], + ), + ), + ), + ); + } + + static IconData _iconFor(ImportWarningSeverity s) => switch (s) { + ImportWarningSeverity.info => Icons.info_outline, + ImportWarningSeverity.warning => Icons.warning_amber_rounded, + ImportWarningSeverity.error => Icons.error_outline, + }; + + static Color _colorFor(ImportWarningSeverity s, ThemeData theme) => + switch (s) { + ImportWarningSeverity.info => theme.colorScheme.primary, + ImportWarningSeverity.warning => theme.colorScheme.tertiary, + ImportWarningSeverity.error => theme.colorScheme.error, + }; +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2d37d4a1a8..659186089c 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1624,6 +1624,7 @@ "diveImport_uddf_tabTrips": "الرحلات", "diveImport_uddf_tabTypes": "الأنواع", "diveImport_uddf_tags": "الوسوم", + "diveImport_uddf_media": "الصور", "diveImport_uddf_title": "استيراد من UDDF", "diveImport_uddf_toggleDiveSelection": "تبديل تحديد الغطسة", "diveImport_uddf_toggleEntitySelection": "تبديل تحديد {name}", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 8dff17a795..f9303e1df6 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1624,6 +1624,7 @@ "diveImport_uddf_tabTrips": "Reisen", "diveImport_uddf_tabTypes": "Typen", "diveImport_uddf_tags": "Tags", + "diveImport_uddf_media": "Fotos", "diveImport_uddf_title": "Aus UDDF importieren", "diveImport_uddf_toggleDiveSelection": "Auswahl für Tauchgang umschalten", "diveImport_uddf_toggleEntitySelection": "Auswahl für {name} umschalten", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5822a527b7..0bda01382a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -13391,6 +13391,10 @@ "diveImport_uddf_tabTrips": "Trips", "diveImport_uddf_tabTypes": "Types", "diveImport_uddf_tags": "Tags", + "diveImport_uddf_media": "Photos", + "@diveImport_uddf_media": { + "description": "Entity type label for photos referenced by an imported logbook" + }, "diveImport_uddf_title": "Import from UDDF", "diveImport_uddf_toggleDiveSelection": "Toggle selection for dive", "diveImport_uddf_toggleEntitySelection": "Toggle selection for {name}", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 30cb99c311..20c86b7ef2 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1624,6 +1624,7 @@ "diveImport_uddf_tabTrips": "Viajes", "diveImport_uddf_tabTypes": "Tipos", "diveImport_uddf_tags": "Etiquetas", + "diveImport_uddf_media": "Fotos", "diveImport_uddf_title": "Importar desde UDDF", "diveImport_uddf_toggleDiveSelection": "Alternar seleccion de inmersion", "diveImport_uddf_toggleEntitySelection": "Alternar seleccion de {name}", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index dbf8c6029f..891bb28d0c 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1551,6 +1551,7 @@ "diveImport_uddf_tabTrips": "Voyages", "diveImport_uddf_tabTypes": "Types", "diveImport_uddf_tags": "Tags", + "diveImport_uddf_media": "Photos", "diveImport_uddf_title": "Import depuis UDDF", "diveImport_uddf_toggleDiveSelection": "Basculer la selection de la plongee", "diveImport_uddf_toggleEntitySelection": "Basculer la selection de {name}", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 00e80d2c03..668b5a79db 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1551,6 +1551,7 @@ "diveImport_uddf_tabTrips": "טיולים", "diveImport_uddf_tabTypes": "סוגים", "diveImport_uddf_tags": "תגיות", + "diveImport_uddf_media": "תמונות", "diveImport_uddf_title": "ייבוא מ-UDDF", "diveImport_uddf_toggleDiveSelection": "החלף בחירת צלילה", "diveImport_uddf_toggleEntitySelection": "החלף בחירה עבור {name}", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 27b0a9ff53..e82bab3f8a 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1551,6 +1551,7 @@ "diveImport_uddf_tabTrips": "Utak", "diveImport_uddf_tabTypes": "Tipusok", "diveImport_uddf_tags": "Cimkek", + "diveImport_uddf_media": "Fényképek", "diveImport_uddf_title": "Importalas UDDF-bol", "diveImport_uddf_toggleDiveSelection": "Merules kijelolesenek valtasa", "diveImport_uddf_toggleEntitySelection": "{name} kijelolesenek valtasa", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 46281875a5..f9197cab6f 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1551,6 +1551,7 @@ "diveImport_uddf_tabTrips": "Viaggi", "diveImport_uddf_tabTypes": "Tipi", "diveImport_uddf_tags": "Tag", + "diveImport_uddf_media": "Foto", "diveImport_uddf_title": "Importa da UDDF", "diveImport_uddf_toggleDiveSelection": "Seleziona/deseleziona immersione", "diveImport_uddf_toggleEntitySelection": "Seleziona/deseleziona {name}", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b5a83cf5f..6457188b76 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -35033,6 +35033,12 @@ abstract class AppLocalizations { /// **'Tags'** String get diveImport_uddf_tags; + /// Entity type label for photos referenced by an imported logbook + /// + /// In en, this message translates to: + /// **'Photos'** + String get diveImport_uddf_media; + /// No description provided for @diveImport_uddf_title. /// /// 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..bb38e42eee 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -20666,6 +20666,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveImport_uddf_tags => 'الوسوم'; + @override + String get diveImport_uddf_media => 'الصور'; + @override String get diveImport_uddf_title => 'استيراد من UDDF'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index c37560c8f2..7eb1bd40c0 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -21010,6 +21010,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveImport_uddf_tags => 'Tags'; + @override + String get diveImport_uddf_media => 'Fotos'; + @override String get diveImport_uddf_title => 'Aus UDDF importieren'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 9ccf622dbd..1991f1bba2 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -20687,6 +20687,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveImport_uddf_tags => 'Tags'; + @override + String get diveImport_uddf_media => 'Photos'; + @override String get diveImport_uddf_title => 'Import from UDDF'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d3c29f3726..b116c894a0 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -21057,6 +21057,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveImport_uddf_tags => 'Etiquetas'; + @override + String get diveImport_uddf_media => 'Fotos'; + @override String get diveImport_uddf_title => 'Importar desde UDDF'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e3ffb8fbdb..57dbe079e1 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -21115,6 +21115,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diveImport_uddf_tags => 'Tags'; + @override + String get diveImport_uddf_media => 'Photos'; + @override String get diveImport_uddf_title => 'Import depuis UDDF'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index efcb2fa427..bb2cdaa237 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -20519,6 +20519,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveImport_uddf_tags => 'תגיות'; + @override + String get diveImport_uddf_media => 'תמונות'; + @override String get diveImport_uddf_title => 'ייבוא מ-UDDF'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 74feb717b9..ce9f1b1798 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -20978,6 +20978,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get diveImport_uddf_tags => 'Cimkek'; + @override + String get diveImport_uddf_media => 'Fényképek'; + @override String get diveImport_uddf_title => 'Importalas UDDF-bol'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f3b20b8ab7..c22c81ec05 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -21040,6 +21040,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diveImport_uddf_tags => 'Tag'; + @override + String get diveImport_uddf_media => 'Foto'; + @override String get diveImport_uddf_title => 'Importa da UDDF'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index c8db1b58f2..e051717524 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -20881,6 +20881,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveImport_uddf_tags => 'Tags'; + @override + String get diveImport_uddf_media => 'Foto\'s'; + @override String get diveImport_uddf_title => 'Importeren vanuit UDDF'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cbf7c2f7db..d6e70f4ad1 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -21042,6 +21042,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveImport_uddf_tags => 'Tags'; + @override + String get diveImport_uddf_media => 'Fotos'; + @override String get diveImport_uddf_title => 'Importar de UDDF'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2de81d2c2b..46d696423e 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -19983,6 +19983,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveImport_uddf_tags => '标签'; + @override + String get diveImport_uddf_media => '照片'; + @override String get diveImport_uddf_title => '从 UDDF 导入'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index fc0a07071f..d371acde94 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1624,6 +1624,7 @@ "diveImport_uddf_tabTrips": "Reizen", "diveImport_uddf_tabTypes": "Types", "diveImport_uddf_tags": "Tags", + "diveImport_uddf_media": "Foto's", "diveImport_uddf_title": "Importeren vanuit UDDF", "diveImport_uddf_toggleDiveSelection": "Duikselectie wisselen", "diveImport_uddf_toggleEntitySelection": "Selectie wisselen voor {name}", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 376335cfb2..b3cada17ab 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1624,6 +1624,7 @@ "diveImport_uddf_tabTrips": "Viagens", "diveImport_uddf_tabTypes": "Tipos", "diveImport_uddf_tags": "Tags", + "diveImport_uddf_media": "Fotos", "diveImport_uddf_title": "Importar de UDDF", "diveImport_uddf_toggleDiveSelection": "Alternar selecao do mergulho", "diveImport_uddf_toggleEntitySelection": "Alternar selecao de {name}", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index b47336d29b..699b8aac5a 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1718,6 +1718,7 @@ "diveImport_uddf_tabTrips": "旅行", "diveImport_uddf_tabTypes": "类型", "diveImport_uddf_tags": "标签", + "diveImport_uddf_media": "照片", "diveImport_uddf_title": "从 UDDF 导入", "diveImport_uddf_toggleDiveSelection": "切换潜水记录选择", "diveImport_uddf_toggleEntitySelection": "切换 {name} 的选择", diff --git a/test/features/import_wizard/data/services/import_provider_invalidator_test.dart b/test/features/import_wizard/data/services/import_provider_invalidator_test.dart index 5569099ca7..5bb19af69e 100644 --- a/test/features/import_wizard/data/services/import_provider_invalidator_test.dart +++ b/test/features/import_wizard/data/services/import_provider_invalidator_test.dart @@ -13,6 +13,7 @@ import 'package:submersion/features/equipment/presentation/providers/equipment_s import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; // --------------------------------------------------------------------------- // Testable mirror of invalidateImportRelatedProviders. @@ -71,6 +72,12 @@ void _invalidateWithCallback( case ImportEntityType.diveTypes: invalidate(diveTypesProvider); + + case ImportEntityType.media: + // Photos land on dives that may already be on screen. + invalidate(mediaForDiveProvider); + invalidate(mediaCountForDiveProvider); + invalidate(mediaListNotifierProvider); } } } diff --git a/test/features/universal_import/data/services/payload_merger_test.dart b/test/features/universal_import/data/services/payload_merger_test.dart index ed86f5dea9..da53da883e 100644 --- a/test/features/universal_import/data/services/payload_merger_test.dart +++ b/test/features/universal_import/data/services/payload_merger_test.dart @@ -343,4 +343,70 @@ void main() { expect(merged.warnings, hasLength(1)); }); }); + + group('media', () { + ImportPayload mediaPayload({ + required int diveCount, + required List pictureDiveIndices, + }) { + return ImportPayload( + entities: { + ImportEntityType.dives: [ + for (var i = 0; i < diveCount; i++) + {'uddfId': 'd$i', 'dateTime': DateTime(2025, 1, 1 + i)}, + ], + ImportEntityType.media: [ + for (final index in pictureDiveIndices) + { + 'filename': '/home/jai/Pictures/p$index.jpg', + 'offsetSeconds': 200, + '_diveIndex': index, + }, + ], + }, + ); + } + + test('rebases _diveIndex onto the merged dive list', () { + final merged = const PayloadMerger().merge([ + FilePayload( + fileId: 'f0', + fileName: 'first.ssrf', + payload: mediaPayload(diveCount: 2, pictureDiveIndices: [0, 1]), + ), + FilePayload( + fileId: 'f1', + fileName: 'second.ssrf', + payload: mediaPayload(diveCount: 3, pictureDiveIndices: [0, 2]), + ), + ]); + + final dives = merged.entitiesOf(ImportEntityType.dives); + final media = merged.entitiesOf(ImportEntityType.media); + expect(dives, hasLength(5)); + expect(media, hasLength(4)); + // First file's pictures keep their indices; second file's shift by 2. + expect(media.map((m) => m['_diveIndex']), [0, 1, 2, 4]); + }); + + test('never folds two pictures with the same filename', () { + final payload = ImportPayload( + entities: { + ImportEntityType.dives: [ + {'uddfId': 'd0', 'dateTime': DateTime(2025, 1, 1)}, + ], + ImportEntityType.media: [ + {'filename': '/p/same.jpg', '_diveIndex': 0}, + {'filename': '/p/same.jpg', '_diveIndex': 0}, + ], + }, + ); + + final merged = const PayloadMerger().merge([ + FilePayload(fileId: 'f0', fileName: 'a.ssrf', payload: payload), + ]); + + expect(merged.entitiesOf(ImportEntityType.media), hasLength(2)); + }); + }); } From 917ccb094e02204e67ecc77c68a5e44aff2c4093 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:26:13 -0400 Subject: [PATCH 076/122] feat(import): parse Subsurface elements Collects filename, signed offset and gps from both dive-walk paths, pointing each picture at its owning dive by index. Refs #1147 --- .../data/parsers/subsurface_xml_parser.dart | 73 +++++++++ .../parsers/subsurface_xml_parser_test.dart | 146 ++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/lib/features/universal_import/data/parsers/subsurface_xml_parser.dart b/lib/features/universal_import/data/parsers/subsurface_xml_parser.dart index 53ac2f9543..555efeafed 100644 --- a/lib/features/universal_import/data/parsers/subsurface_xml_parser.dart +++ b/lib/features/universal_import/data/parsers/subsurface_xml_parser.dart @@ -92,6 +92,7 @@ class SubsurfaceXmlParser implements ImportParser { final trips = >[]; final allTags = >{}; final allBuddies = >{}; + final allMedia = >[]; // Process trip-wrapped dives for (final tripElement in divesElement.findElements('trip')) { @@ -107,6 +108,9 @@ class SubsurfaceXmlParser implements ImportParser { diveData['tripRef'] = tripId; _collectTags(diveElement, diveData, allTags); _collectBuddies(diveElement, diveData, allBuddies); + // dives.length is this dive's index, because the pictures are + // collected before the dive is appended. + _collectPictures(diveElement, dives.length, allMedia, warnings); dives.add(diveData); tripDives.add(diveData); } @@ -140,6 +144,7 @@ class SubsurfaceXmlParser implements ImportParser { if (diveData != null) { _collectTags(diveElement, diveData, allTags); _collectBuddies(diveElement, diveData, allBuddies); + _collectPictures(diveElement, dives.length, allMedia, warnings); dives.add(diveData); } } catch (e) { @@ -155,6 +160,7 @@ class SubsurfaceXmlParser implements ImportParser { if (dives.isNotEmpty) entities[ImportEntityType.dives] = dives; if (trips.isNotEmpty) entities[ImportEntityType.trips] = trips; + if (allMedia.isNotEmpty) entities[ImportEntityType.media] = allMedia; if (allTags.isNotEmpty) { entities[ImportEntityType.tags] = allTags.values.toList(); } @@ -467,6 +473,73 @@ class SubsurfaceXmlParser implements ImportParser { } } + /// Collects `` elements from [diveElement] into [allMedia]. + /// + /// Subsurface stores an absolute path from the exporting machine, so + /// `filename` is kept verbatim (Windows separators included) and resolved + /// later against a user-picked folder. `offset` is signed and relative to + /// dive start; a picture taken before the dive began carries a negative + /// offset. An unparseable offset costs the picture its timestamp, not its + /// import, so it is kept with a null offset. + void _collectPictures( + XmlElement diveElement, + int diveIndex, + List> allMedia, + List warnings, + ) { + for (final picture in diveElement.findElements('picture')) { + final filename = picture.getAttribute('filename')?.trim(); + if (filename == null || filename.isEmpty) { + warnings.add( + const ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'Skipped a photo with no filename', + entityType: ImportEntityType.media, + ), + ); + continue; + } + + final gps = _parseGpsPair(picture.getAttribute('gps')); + allMedia.add({ + 'filename': filename, + 'offsetSeconds': _parseSignedDurationSeconds( + picture.getAttribute('offset'), + ), + 'latitude': gps?.$1, + 'longitude': gps?.$2, + '_diveIndex': diveIndex, + }); + } + } + + /// Parses a signed Subsurface duration: '+3:20 min', '-1:05 min', '3:20 min'. + /// + /// Returns null when the value is absent or malformed. The sign applies to + /// the whole duration, so '-1:05 min' is -65 seconds, not -60 plus 5. + static int? _parseSignedDurationSeconds(String? value) { + if (value == null || value.isEmpty) return null; + final trimmed = value.trim(); + final negative = trimmed.startsWith('-'); + final magnitude = (negative || trimmed.startsWith('+')) + ? trimmed.substring(1) + : trimmed; + final seconds = _parseDurationSeconds(magnitude); + if (seconds == null) return null; + return negative ? -seconds : seconds; + } + + /// Parses a Subsurface `gps` attribute: two space-separated decimal degrees. + static (double, double)? _parseGpsPair(String? value) { + if (value == null || value.isEmpty) return null; + final parts = value.trim().split(RegExp(r'\s+')); + if (parts.length != 2) return null; + final latitude = double.tryParse(parts[0]); + final longitude = double.tryParse(parts[1]); + if (latitude == null || longitude == null) return null; + return (latitude, longitude); + } + /// Parses `` elements from a `` into profile points. /// /// Subsurface only records tank pressure on a subset of samples (when the diff --git a/test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart b/test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart index b3ae3417ac..4db88424cd 100644 --- a/test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart +++ b/test/features/universal_import/data/parsers/subsurface_xml_parser_test.dart @@ -2467,4 +2467,150 @@ $diveXml }, ); }); + group('picture parsing', () { + test( + 'parses filename, offset and gps, pointing at the owning dive', + () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media, hasLength(1)); + expect(media.first['filename'], '/home/jai/Pictures/2025/dive042.jpg'); + expect(media.first['offsetSeconds'], 200); + expect(media.first['latitude'], closeTo(18.465562, 1e-6)); + expect(media.first['longitude'], closeTo(-66.084902, 1e-6)); + expect(media.first['_diveIndex'], 0); + }, + ); + + test('parses a negative offset', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + expect( + result.entitiesOf(ImportEntityType.media).single['offsetSeconds'], + -65, + ); + }); + + test('keeps a picture whose offset is unparseable', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media, hasLength(1)); + expect(media.single['offsetSeconds'], isNull); + }); + + test( + 'keeps a Windows path verbatim for the resolver to normalise', + () async { + final result = await parser.parse( + xmlBytes(r''' + + + + + + + +'''), + ); + + expect( + result.entitiesOf(ImportEntityType.media).single['filename'], + r'C:\Users\jai\Pictures\dive042.jpg', + ); + }, + ); + + test('drops a picture with no filename and warns', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + expect(result.entitiesOf(ImportEntityType.media), isEmpty); + expect( + result.warnings.any((w) => w.entityType == ImportEntityType.media), + isTrue, + ); + }); + + test('collects pictures from trip-wrapped dives too', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media, hasLength(2)); + // Trip dives are walked first, so the trip picture points at dive 0. + expect(media.map((m) => [m['filename'], m['_diveIndex']]), [ + ['/p/trip.jpg', 0], + ['/p/solo.jpg', 1], + ]); + }); + + test('omits the media key when a logbook has no pictures', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + +'''), + ); + + expect(result.entities.containsKey(ImportEntityType.media), isFalse); + }); + }); } From 24fc01e91344cf1b669bd8c106966214c75a232b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:27:10 -0400 Subject: [PATCH 077/122] fix(media): key the repair harvest by basename on every platform lastIndexOf('/') indexed the whole path as the key on Windows, where Directory.list yields backslash-separated paths, so every filename lookup missed. The existing harvest test locks the POSIX behaviour and still passes; the Windows case cannot be reproduced on a POSIX host. Refs #1147 --- .../data/services/repair/folder_candidate_source.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/features/media/data/services/repair/folder_candidate_source.dart b/lib/features/media/data/services/repair/folder_candidate_source.dart index cd85100476..af10207f9d 100644 --- a/lib/features/media/data/services/repair/folder_candidate_source.dart +++ b/lib/features/media/data/services/repair/folder_candidate_source.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:path/path.dart' as p; + import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/media_store/store_keys.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; @@ -38,9 +40,11 @@ class FolderCandidateSource implements CandidateSource { if (entity is! File) continue; final stat = await entity.stat(); final path = entity.path; - final slash = path.lastIndexOf('/'); - final name = (slash >= 0 ? path.substring(slash + 1) : path) - .toLowerCase(); + // p.basename follows the HOST's separator. A hand-rolled + // lastIndexOf('/') indexed the entire path as the key on Windows, + // where Directory.list yields backslash-separated paths, so every + // filename lookup missed. + final name = p.basename(path).toLowerCase(); foundPaths.add(path); byFilename .putIfAbsent(name, () => []) From 2d2b0f4ddc35176f5223f0954cdbf9d055ae07bf Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:28:41 -0400 Subject: [PATCH 078/122] feat(import): resolve referenced photos against a picked folder Dresses each payload media entry as a transient MediaItem and runs it through the existing media repair ladder, so import and repair read a moved photo library the same way. Foreign paths are normalised first, because a logbook exported from Windows keeps its backslashes wherever it is imported. Refs #1147 --- .../services/import_media_resolver.dart | 164 ++++++++++++++++++ .../services/import_media_resolver_test.dart | 151 ++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 lib/features/universal_import/domain/services/import_media_resolver.dart create mode 100644 test/features/universal_import/domain/services/import_media_resolver_test.dart diff --git a/lib/features/universal_import/domain/services/import_media_resolver.dart b/lib/features/universal_import/domain/services/import_media_resolver.dart new file mode 100644 index 0000000000..891e82c7db --- /dev/null +++ b/lib/features/universal_import/domain/services/import_media_resolver.dart @@ -0,0 +1,164 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; + +import 'package:submersion/features/media/data/services/repair/folder_candidate_source.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/services/media_repair_matcher.dart'; +import 'package:submersion/features/media/domain/services/media_repair_types.dart'; + +/// The outcome of resolving a payload's media entries against a folder root. +class ImportMediaResolution { + const ImportMediaResolution({ + required this.resolvedPathByIndex, + required this.reRootedCount, + required this.filenameOnlyCount, + required this.notFoundCount, + }); + + const ImportMediaResolution.empty() + : resolvedPathByIndex = const {}, + reRootedCount = 0, + filenameOnlyCount = 0, + notFoundCount = 0; + + /// Local path on this machine, keyed by the picture's index in the payload + /// media list. A picture that resolved to nothing is absent. + final Map resolvedPathByIndex; + + /// Matched by re-rooting the whole moved tree. The strongest signal here: + /// the picture sits at the same relative position it did on the exporting + /// machine. + final int reRootedCount; + + /// Matched on filename alone, somewhere under the root. Weaker: a + /// reorganised library resolves this way, and so does a coincidence. + final int filenameOnlyCount; + + /// Found nowhere under the root. + final int notFoundCount; + + int get matchedCount => resolvedPathByIndex.length; +} + +/// Resolves the foreign absolute paths a logbook references against a folder +/// the user picked on this machine. +/// +/// Deliberately format-agnostic: it knows only the payload media contract +/// (a `filename` plus a position in the list), so a UDDF `` parser +/// can feed the same resolver without changing anything here. +/// +/// No matching logic lives in this class. Resolution IS the media repair +/// ladder, reached by dressing each picture as a transient unsaved +/// [MediaItem]: harvest the folder into a filename index, detect a wholesale +/// tree move, then run the ladder. Keeping the two features on one matcher +/// means a moved photo library is read the same way whether the user arrives +/// via import or via repair. +class ImportMediaResolver { + const ImportMediaResolver(); + + Future resolve({ + required List> media, + required String rootPath, + }) async { + if (media.isEmpty) return const ImportMediaResolution.empty(); + + // A picture with no usable filename can never resolve, but it still has + // to be counted, so it is excluded from the ladder and folded into the + // not-found tally below. + final items = {}; + for (var i = 0; i < media.length; i++) { + final filename = (media[i]['filename'] as String?)?.trim(); + if (filename == null || filename.isEmpty) continue; + items[i] = _transientItem(filename); + } + + if (items.isEmpty) { + return ImportMediaResolution( + resolvedPathByIndex: const {}, + reRootedCount: 0, + filenameOnlyCount: 0, + notFoundCount: media.length, + ); + } + + final indices = items.keys.toList(); + final rows = [for (final index in indices) items[index]!]; + + final harvest = await FolderCandidateSource( + roots: [rootPath], + ).harvest(rows); + final prefixMove = detectPrefixMove( + brokenPaths: [for (final row in rows) row.filePath!], + foundPaths: harvest.foundPaths, + ); + final proposals = buildRepairProposals( + brokenRows: rows, + candidatesByFilename: harvest.byFilename, + prefixMove: prefixMove, + foundPaths: harvest.foundPaths, + ); + + final resolved = {}; + var reRooted = 0; + var filenameOnly = 0; + var notFound = media.length - items.length; + + for (var i = 0; i < proposals.length; i++) { + final proposal = proposals[i]; + final path = proposal.candidate?.path; + if (proposal.confidence == RepairConfidence.unmatched || path == null) { + notFound++; + continue; + } + resolved[indices[i]] = path; + if (proposal.viaPrefixMove) { + reRooted++; + } else { + filenameOnly++; + } + } + + return ImportMediaResolution( + resolvedPathByIndex: resolved, + reRootedCount: reRooted, + filenameOnlyCount: filenameOnly, + notFoundCount: notFound, + ); + } + + /// A [MediaItem] that is never persisted. It exists only to satisfy the + /// repair ladder's parameter type; the ladder reads `filePath` and + /// `originalFilename` and nothing else. + /// + /// The path came from another machine, possibly another platform, so both + /// fields are normalised here rather than left for the ladder to guess. + /// `originalFilename` is set explicitly because the ladder prefers it over + /// parsing the path, which spares it the separator question entirely. + static MediaItem _transientItem(String foreignPath) { + final epoch = DateTime.fromMillisecondsSinceEpoch(0); + return MediaItem( + id: '', + mediaType: MediaType.photo, + sourceType: MediaSourceType.localFile, + filePath: foreignPath.replaceAll(r'\', '/'), + originalFilename: foreignBasename(foreignPath), + takenAt: epoch, + createdAt: epoch, + updatedAt: epoch, + ); + } +} + +/// Basename of a path produced by an unknown platform. +/// +/// `p.basename` follows the HOST's separator, which is the wrong question for +/// a path that arrived inside a file: a logbook exported from Windows carries +/// `C:\Users\jai\dive.jpg` no matter which platform imports it. Both +/// separators are therefore treated as separators, which is safe in practice +/// because a photo filename containing a literal backslash is vanishingly +/// rare next to the certainty of Windows-exported logbooks. +@visibleForTesting +String foreignBasename(String path) { + final index = path.lastIndexOf(RegExp(r'[/\\]')); + return index < 0 ? path : path.substring(index + 1); +} diff --git a/test/features/universal_import/domain/services/import_media_resolver_test.dart b/test/features/universal_import/domain/services/import_media_resolver_test.dart new file mode 100644 index 0000000000..860c88f682 --- /dev/null +++ b/test/features/universal_import/domain/services/import_media_resolver_test.dart @@ -0,0 +1,151 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:submersion/features/universal_import/domain/services/import_media_resolver.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('import_media_resolver_'); + }); + + tearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + + Future writeFile(String relativePath) async { + final file = File(p.join(root.path, relativePath)); + await file.parent.create(recursive: true); + await file.writeAsString('bytes'); + } + + Map picture(String filename, {int index = 0}) => { + 'filename': filename, + 'offsetSeconds': 200, + '_diveIndex': index, + }; + + test('re-roots a whole moved tree', () async { + await writeFile(p.join('2025', 'dive042.jpg')); + await writeFile(p.join('2025', 'dive043.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [ + picture('/home/jai/Pictures/2025/dive042.jpg'), + picture('/home/jai/Pictures/2025/dive043.jpg', index: 1), + ], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 2); + expect(resolution.reRootedCount, 2); + expect(resolution.filenameOnlyCount, 0); + expect(resolution.notFoundCount, 0); + expect( + resolution.resolvedPathByIndex[0], + p.join(root.path, '2025', 'dive042.jpg'), + ); + }); + + test('falls back to a filename match in a reorganised tree', () async { + await writeFile(p.join('Archive', 'Bonaire', 'dive042.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/2025/dive042.jpg')], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 1); + expect(resolution.filenameOnlyCount, 1); + expect(resolution.reRootedCount, 0); + expect( + resolution.resolvedPathByIndex[0], + p.join(root.path, 'Archive', 'Bonaire', 'dive042.jpg'), + ); + }); + + test('reports a picture that is nowhere under the root', () async { + await writeFile(p.join('2025', 'other.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/2025/missing.jpg')], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 0); + expect(resolution.notFoundCount, 1); + expect(resolution.resolvedPathByIndex, isEmpty); + }); + + test('resolves an ambiguous filename to a single candidate', () async { + await writeFile(p.join('a', 'dive042.jpg')); + await writeFile(p.join('b', 'dive042.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/dive042.jpg')], + rootPath: root.path, + ); + + // One picture yields at most one resolved path; which of the two + // candidates wins is not contractual, only that it resolves exactly once + // and is reported as a filename-only match. + expect(resolution.matchedCount, 1); + expect(resolution.filenameOnlyCount, 1); + }); + + test('reports every picture as not found when the root is missing', () async { + final resolution = await const ImportMediaResolver().resolve( + media: [picture('/home/jai/Pictures/dive042.jpg')], + rootPath: p.join(root.path, 'no-such-folder'), + ); + + expect(resolution.matchedCount, 0); + expect(resolution.notFoundCount, 1); + }); + + test('resolves a path exported from Windows', () async { + await writeFile(p.join('2025', 'dive042.jpg')); + + final resolution = await const ImportMediaResolver().resolve( + media: [picture(r'C:\Users\jai\Pictures\2025\dive042.jpg')], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 1); + expect( + resolution.resolvedPathByIndex[0], + p.join(root.path, '2025', 'dive042.jpg'), + ); + }); + + test('foreignBasename treats both separators as separators', () { + expect(foreignBasename(r'C:\Users\jai\dive.jpg'), 'dive.jpg'); + expect(foreignBasename('/home/jai/dive.jpg'), 'dive.jpg'); + expect(foreignBasename('dive.jpg'), 'dive.jpg'); + }); + + test('skips a picture whose filename is missing or empty', () async { + final resolution = await const ImportMediaResolver().resolve( + media: [ + {'offsetSeconds': 1, '_diveIndex': 0}, + {'filename': '', '_diveIndex': 1}, + ], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 0); + expect(resolution.notFoundCount, 2); + }); + + test('an empty media list resolves to nothing without scanning', () async { + final resolution = await const ImportMediaResolver().resolve( + media: const [], + rootPath: root.path, + ); + + expect(resolution.matchedCount, 0); + expect(resolution.notFoundCount, 0); + }); +} From f8dacde3c170bd69588206bf308fbbd4089c5915 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:30:50 -0400 Subject: [PATCH 079/122] feat(media): let importLocalFileForDive carry coordinates and a destination The OCR caller keeps its scanned_logs default; file imports pass their own subdirectory and the photo's own coordinates. Also fixes a latent overwrite: the destination name was a bare millisecond stamp, so two copies inside the same millisecond collided and the second clobbered the first. A batch of twenty rapid copies reproduces it; the test fails without the counter suffix. Refs #1147 --- .../data/services/media_import_service.dart | 30 +++++-- .../media_import_local_file_test.dart | 82 +++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/lib/features/media/data/services/media_import_service.dart b/lib/features/media/data/services/media_import_service.dart index 1ca01815cc..9a696d2a4b 100644 --- a/lib/features/media/data/services/media_import_service.dart +++ b/lib/features/media/data/services/media_import_service.dart @@ -65,20 +65,38 @@ class MediaImportService { final void Function(String mediaId)? onMediaCreated; /// Copies [sourceFile] into the app documents directory (subdir - /// 'scanned_logs/') and creates a localFile media row linked to - /// [diveId]. Used by the OCR scan flow to attach the source page photo. + /// [subdirectory]) and creates a localFile media row linked to [diveId]. + /// + /// [subdirectory] defaults to 'scanned_logs' for the OCR scan flow that + /// introduced this method; file imports pass their own so an imported + /// logbook's photos are not filed as scanned pages. + /// + /// [latitude] and [longitude] are the photo's own coordinates when the + /// source recorded them, which is not the same as the dive site's. Future importLocalFileForDive({ required File sourceFile, required String diveId, DateTime? takenAt, + double? latitude, + double? longitude, + String subdirectory = 'scanned_logs', }) async { final docs = await _documentsDirectory(); - final dir = Directory(p.join(docs.path, 'scanned_logs')); + final dir = Directory(p.join(docs.path, subdirectory)); await dir.create(recursive: true); final sourceExt = p.extension(sourceFile.path); final ext = sourceExt.isEmpty ? '.jpg' : sourceExt; - final destName = '${DateTime.now().millisecondsSinceEpoch}$ext'; - final dest = await sourceFile.copy(p.join(dir.path, destName)); + // A millisecond stamp alone collides when a batch import copies two + // photos inside the same millisecond, and the second copy would + // overwrite the first. Disambiguate with a counter. + var destName = '${DateTime.now().millisecondsSinceEpoch}$ext'; + var destPath = p.join(dir.path, destName); + var counter = 1; + while (File(destPath).existsSync()) { + destName = '${DateTime.now().millisecondsSinceEpoch}_${counter++}$ext'; + destPath = p.join(dir.path, destName); + } + final dest = await sourceFile.copy(destPath); final now = DateTime.now(); final item = MediaItem( id: '', @@ -87,6 +105,8 @@ class MediaImportService { sourceType: MediaSourceType.localFile, filePath: dest.path, originalFilename: p.basename(sourceFile.path), + latitude: latitude, + longitude: longitude, takenAt: takenAt ?? now, createdAt: now, updatedAt: now, diff --git a/test/features/media/data/services/media_import_local_file_test.dart b/test/features/media/data/services/media_import_local_file_test.dart index cb58aad455..d39a16f21e 100644 --- a/test/features/media/data/services/media_import_local_file_test.dart +++ b/test/features/media/data/services/media_import_local_file_test.dart @@ -67,4 +67,86 @@ void main() { expect(item.filePath, endsWith('.jpg')); }); + group('coordinates and destination', () { + test( + 'stores coordinates and writes into the requested subdirectory', + () async { + final source = File('${sourceDir.path}/photo.jpg') + ..writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); + + final item = await service.importLocalFileForDive( + sourceFile: source, + diveId: 'dive-1', + takenAt: DateTime.utc(2025, 1, 15, 10, 3, 20), + latitude: 18.465562, + longitude: -66.084902, + subdirectory: 'imported_photos', + ); + + expect(item.latitude, closeTo(18.465562, 1e-6)); + expect(item.longitude, closeTo(-66.084902, 1e-6)); + expect(item.takenAt, DateTime.utc(2025, 1, 15, 10, 3, 20)); + expect(item.filePath, contains('imported_photos')); + expect(item.filePath, isNot(contains('scanned_logs'))); + }, + ); + + test('defaults to scanned_logs with no coordinates', () async { + final source = File('${sourceDir.path}/scan2.jpg') + ..writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); + + final item = await service.importLocalFileForDive( + sourceFile: source, + diveId: 'dive-1', + ); + + expect(item.latitude, isNull); + expect(item.longitude, isNull); + expect(item.filePath, contains('scanned_logs')); + }); + + test('a rapid batch never overwrites an earlier copy', () async { + // Twenty copies in a tight loop reliably land several inside the same + // millisecond, which is the case the counter suffix exists for. + final paths = {}; + for (var i = 0; i < 20; i++) { + final source = File('${sourceDir.path}/batch$i.jpg') + ..writeAsBytesSync([0xFF, 0xD8, 0xFF, i]); + final item = await service.importLocalFileForDive( + sourceFile: source, + diveId: 'dive-1', + subdirectory: 'imported_photos', + ); + paths.add(item.filePath!); + // Each copy must hold its OWN bytes, not a later one's. + expect(File(item.filePath!).readAsBytesSync().last, i); + } + expect(paths, hasLength(20)); + }); + + test('two photos imported back to back get distinct paths', () async { + final a = File('${sourceDir.path}/a.jpg') + ..writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE0]); + final b = File('${sourceDir.path}/b.jpg') + ..writeAsBytesSync([0xFF, 0xD8, 0xFF, 0xE1]); + + final first = await service.importLocalFileForDive( + sourceFile: a, + diveId: 'dive-1', + subdirectory: 'imported_photos', + ); + final second = await service.importLocalFileForDive( + sourceFile: b, + diveId: 'dive-1', + subdirectory: 'imported_photos', + ); + + expect(first.filePath, isNot(second.filePath)); + expect(File(first.filePath!).existsSync(), isTrue); + expect(File(second.filePath!).existsSync(), isTrue); + // The first copy must still hold its own bytes, not the second's. + expect(File(first.filePath!).readAsBytesSync().last, 0xE0); + expect(File(second.filePath!).readAsBytesSync().last, 0xE1); + }); + }); } From 4b1bed4ddd5b4afb93d8e91b8703a67af4221067 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:34:16 -0400 Subject: [PATCH 080/122] feat(import): hold the picked photo folder and its resolution in wizard state Adds the two step gates: no-photos drives auto-skip, photos-ready drives the Next button, so the step is invisible when there is nothing to ask and never auto-advanced past an unmade decision. Refs #1147 --- .../data/adapters/universal_adapter.dart | 22 ++++ .../providers/universal_import_providers.dart | 47 ++++++++ .../providers/universal_import_state.dart | 28 +++++ .../universal_import_notifier_test.dart | 100 ++++++++++++++++++ 4 files changed, 197 insertions(+) diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index 73f895e508..a4d0d5fd95 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -97,6 +97,28 @@ final _universalAdapterMappingAutoAdvanceProvider = Provider((ref) { return false; }); +/// True when the parsed payload references no photos at all. +/// +/// Used as the Photos step's auto-advance condition, so the step is invisible +/// for every import that has nothing to resolve. +final universalAdapterNoPhotosProvider = Provider((ref) { + final payload = ref.watch( + universalImportNotifierProvider.select((s) => s.payload), + ); + return (payload?.entitiesOf(ui.ImportEntityType.media) ?? const []).isEmpty; +}); + +/// True when the Photos step has nothing left to ask. +/// +/// Deliberately looser than [universalAdapterNoPhotosProvider]: a user who +/// picked a folder or chose to skip may advance, but the step is never +/// auto-advanced past a decision they have not made. +final universalAdapterPhotosReadyProvider = Provider((ref) { + if (ref.watch(universalAdapterNoPhotosProvider)) return true; + final state = ref.watch(universalImportNotifierProvider); + return state.photosSkipped || state.photoResolution != null; +}); + /// Import source adapter for universal file imports (CSV, Subsurface XML, /// UDDF, auto-detected formats). Wraps [UniversalImportNotifier] into the /// unified import wizard framework. 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..e658a7ba26 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -10,6 +10,7 @@ import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; +import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; import 'package:submersion/features/dive_types/presentation/providers/dive_type_providers.dart'; @@ -39,6 +40,7 @@ import 'package:submersion/features/universal_import/data/services/payload_merge import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; import 'package:submersion/features/universal_import/data/services/zip_expansion_service.dart'; +import 'package:submersion/features/universal_import/domain/services/import_media_resolver.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_state.dart'; import 'package:submersion/core/services/files/picked_file_materializer.dart'; @@ -50,6 +52,8 @@ export 'package:submersion/features/universal_import/presentation/providers/univ /// Manages the universal import wizard flow. class UniversalImportNotifier extends StateNotifier { + static const _log = LoggerService('UniversalImportNotifier'); + UniversalImportNotifier( this._ref, { BatchParseService batchParseService = const BatchParseService(), @@ -402,6 +406,49 @@ class UniversalImportNotifier extends StateNotifier { } /// Desktop only: pick a folder and recursively gather importable files. + /// Resolves the payload's referenced photos against [rootPath]. + /// + /// Never throws into the wizard: a scan failure resolves to zero matches + /// and the user can pick a different folder or skip. Photos must not be + /// able to block a dive import. + Future resolvePhotosIn(String rootPath) async { + final media = state.payload?.entitiesOf(ImportEntityType.media); + if (media == null || media.isEmpty) return; + + state = state.copyWith(photoFolderPath: rootPath, isLoading: true); + + ImportMediaResolution resolution; + try { + resolution = await const ImportMediaResolver().resolve( + media: media, + rootPath: rootPath, + ); + } catch (e) { + _log.warning('Photo resolution failed under $rootPath: $e'); + resolution = ImportMediaResolution( + resolvedPathByIndex: const {}, + reRootedCount: 0, + filenameOnlyCount: 0, + notFoundCount: media.length, + ); + } + + state = state.copyWith( + photoResolution: resolution, + photosSkipped: false, + isLoading: false, + ); + } + + /// Proceeds without photos. + void skipPhotos() { + state = state.copyWith( + photosSkipped: true, + clearPhotoResolution: true, + clearPhotoFolderPath: true, + ); + } + Future pickFolder() async { state = state.copyWith( isLoading: true, diff --git a/lib/features/universal_import/presentation/providers/universal_import_state.dart b/lib/features/universal_import/presentation/providers/universal_import_state.dart index 8000852f13..c4c9548569 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_state.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_state.dart @@ -5,6 +5,7 @@ import 'package:submersion/features/universal_import/data/models/field_mapping.d import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/domain/services/import_media_resolver.dart'; import 'package:submersion/features/universal_import/data/models/picked_import_file.dart'; import 'package:submersion/features/universal_import/data/csv/models/parsed_csv.dart'; import 'package:submersion/features/universal_import/data/csv/presets/csv_preset.dart'; @@ -39,6 +40,9 @@ class UniversalImportState { this.files = const [], this.photoPathsByBaseName = const {}, this.unmatchedPhotoCount = 0, + this.photoFolderPath, + this.photoResolution, + this.photosSkipped = false, this.zipTempDirPaths = const [], this.additionalFileBytes, this.additionalFileName, @@ -80,6 +84,18 @@ class UniversalImportState { /// import warning count). final int unmatchedPhotoCount; + /// Folder the user picked to resolve a logbook's referenced photos against. + /// Null until the Photos step runs, and on mobile where it cannot be picked. + final String? photoFolderPath; + + /// Outcome of resolving the payload's media entries against + /// [photoFolderPath]. Null when no folder has been picked. + final ImportMediaResolution? photoResolution; + + /// True once the user has explicitly chosen to import without photos. + /// Distinct from a null [photoResolution], which only means undecided. + final bool photosSkipped; + /// Temp directories holding files extracted from imported ZIP archives. /// The notifier deletes these on reset or when superseded by a new import, /// so extracted dive data and photos do not accumulate on disk. @@ -171,6 +187,11 @@ class UniversalImportState { bool clearFiles = false, Map>? photoPathsByBaseName, int? unmatchedPhotoCount, + String? photoFolderPath, + bool clearPhotoFolderPath = false, + ImportMediaResolution? photoResolution, + bool clearPhotoResolution = false, + bool? photosSkipped, List? zipTempDirPaths, int? parseCurrent, int? parseTotal, @@ -209,6 +230,13 @@ class UniversalImportState { files: clearFiles ? const [] : (files ?? this.files), photoPathsByBaseName: photoPathsByBaseName ?? this.photoPathsByBaseName, unmatchedPhotoCount: unmatchedPhotoCount ?? this.unmatchedPhotoCount, + photoFolderPath: clearPhotoFolderPath + ? null + : (photoFolderPath ?? this.photoFolderPath), + photoResolution: clearPhotoResolution + ? null + : (photoResolution ?? this.photoResolution), + photosSkipped: photosSkipped ?? this.photosSkipped, zipTempDirPaths: zipTempDirPaths ?? this.zipTempDirPaths, parseCurrent: parseCurrent ?? this.parseCurrent, parseTotal: parseTotal ?? this.parseTotal, diff --git a/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart b/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart index 4946efc812..7a9ae8a9f8 100644 --- a/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart +++ b/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart @@ -13,6 +13,7 @@ import 'package:submersion/features/universal_import/data/models/import_options. import 'package:submersion/features/universal_import/data/models/import_payload.dart'; import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; +import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; import '../../../../fixtures/macdive_sqlite/build_synthetic_db.dart'; @@ -1435,5 +1436,104 @@ void main() { }, ); }); + group('photo folder resolution', () { + ImportPayload payloadWithOnePicture(String filename) => ImportPayload( + entities: { + ImportEntityType.dives: [ + {'uddfId': 'd0', 'dateTime': DateTime(2025, 1, 15)}, + ], + ImportEntityType.media: [ + {'filename': filename, '_diveIndex': 0}, + ], + }, + ); + + test('resolvePhotosIn stores the root and the resolution', () async { + final root = await Directory.systemTemp.createTemp('wizard_photos_'); + addTearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + final photo = File('${root.path}/dive042.jpg') + ..writeAsStringSync('bytes'); + + notifier.state = notifier.state.copyWith( + payload: payloadWithOnePicture('/home/jai/Pictures/dive042.jpg'), + ); + + await notifier.resolvePhotosIn(root.path); + + expect(notifier.state.photoFolderPath, root.path); + expect(notifier.state.photoResolution?.matchedCount, 1); + expect( + notifier.state.photoResolution?.resolvedPathByIndex[0], + photo.path, + ); + expect(notifier.state.photosSkipped, isFalse); + expect(notifier.state.isLoading, isFalse); + }); + + test( + 'resolvePhotosIn is a no-op when the payload has no pictures', + () async { + notifier.state = notifier.state.copyWith( + payload: const ImportPayload(entities: {}), + ); + + await notifier.resolvePhotosIn('/nowhere'); + + expect(notifier.state.photoFolderPath, isNull); + expect(notifier.state.photoResolution, isNull); + }, + ); + + test( + 'skipPhotos clears any resolution and marks the step done', + () async { + notifier.state = notifier.state.copyWith( + payload: payloadWithOnePicture('/home/jai/Pictures/dive042.jpg'), + photoFolderPath: '/some/folder', + ); + + notifier.skipPhotos(); + + expect(notifier.state.photosSkipped, isTrue); + expect(notifier.state.photoResolution, isNull); + expect(notifier.state.photoFolderPath, isNull); + expect(container.read(universalAdapterPhotosReadyProvider), isTrue); + }, + ); + + test( + 'the step gate is open with no pictures and shut with unhandled ones', + () { + notifier.state = notifier.state.copyWith( + payload: const ImportPayload(entities: {}), + ); + expect(container.read(universalAdapterNoPhotosProvider), isTrue); + expect(container.read(universalAdapterPhotosReadyProvider), isTrue); + + notifier.state = notifier.state.copyWith( + payload: payloadWithOnePicture('/p/a.jpg'), + ); + expect(container.read(universalAdapterNoPhotosProvider), isFalse); + expect(container.read(universalAdapterPhotosReadyProvider), isFalse); + }, + ); + + test( + 'a missing folder resolves to zero matches without throwing', + () async { + notifier.state = notifier.state.copyWith( + payload: payloadWithOnePicture('/home/jai/Pictures/dive042.jpg'), + ); + + await notifier.resolvePhotosIn('/definitely/not/a/folder'); + + expect(notifier.state.photoResolution?.matchedCount, 0); + expect(notifier.state.photoResolution?.notFoundCount, 1); + expect(container.read(universalAdapterPhotosReadyProvider), isTrue); + }, + ); + }); }); } From 105d54686305f63ad2b1a21c07e3c7078dfe945c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 01:58:01 -0400 Subject: [PATCH 081/122] feat(import): add the Photos step for locating referenced photos Desktop picks a folder and sees the match counts; mobile is told plainly that photo import needs a computer rather than silently receiving a subset. Seven new strings across all 11 locales. Refs #1147 --- .../widgets/photo_folder_step.dart | 121 +++++++++++ lib/l10n/arb/app_ar.arb | 7 + lib/l10n/arb/app_de.arb | 7 + lib/l10n/arb/app_en.arb | 44 ++++ lib/l10n/arb/app_es.arb | 7 + lib/l10n/arb/app_fr.arb | 7 + lib/l10n/arb/app_he.arb | 7 + lib/l10n/arb/app_hu.arb | 7 + lib/l10n/arb/app_it.arb | 7 + lib/l10n/arb/app_localizations.dart | 42 ++++ lib/l10n/arb/app_localizations_ar.dart | 36 ++++ lib/l10n/arb/app_localizations_de.dart | 36 ++++ lib/l10n/arb/app_localizations_en.dart | 36 ++++ lib/l10n/arb/app_localizations_es.dart | 36 ++++ lib/l10n/arb/app_localizations_fr.dart | 37 ++++ lib/l10n/arb/app_localizations_he.dart | 36 ++++ lib/l10n/arb/app_localizations_hu.dart | 36 ++++ lib/l10n/arb/app_localizations_it.dart | 37 ++++ lib/l10n/arb/app_localizations_nl.dart | 36 ++++ lib/l10n/arb/app_localizations_pt.dart | 36 ++++ lib/l10n/arb/app_localizations_zh.dart | 35 ++++ lib/l10n/arb/app_nl.arb | 7 + lib/l10n/arb/app_pt.arb | 7 + lib/l10n/arb/app_zh.arb | 7 + .../pages/dive_edit_prefill_test.dart | 3 + .../widgets/photo_folder_step_test.dart | 191 ++++++++++++++++++ 26 files changed, 868 insertions(+) create mode 100644 lib/features/import_wizard/presentation/widgets/photo_folder_step.dart create mode 100644 test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart diff --git a/lib/features/import_wizard/presentation/widgets/photo_folder_step.dart b/lib/features/import_wizard/presentation/widgets/photo_folder_step.dart new file mode 100644 index 0000000000..a3b6fea66b --- /dev/null +++ b/lib/features/import_wizard/presentation/widgets/photo_folder_step.dart @@ -0,0 +1,121 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, visibleForTesting; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Wizard step for locating the photos a logbook references. +/// +/// Subsurface stores an absolute path from the exporting machine, so the +/// import cannot find the files on its own. This step collects a folder to +/// resolve them against and reports what matched before anything is written. +/// +/// Only shown when the parsed payload actually references photos; see +/// [universalAdapterNoPhotosProvider], which auto-advances past it otherwise. +class PhotoFolderStep extends ConsumerWidget { + const PhotoFolderStep({super.key, this.pickFolderOverride}); + + /// Test seam for the platform directory picker. + @visibleForTesting + final Future Function()? pickFolderOverride; + + /// A recursive folder scan needs real filesystem paths, which Android's SAF + /// does not reliably provide and iOS does not expose at all. + static bool get _canPickFolder => switch (defaultTargetPlatform) { + TargetPlatform.macOS || + TargetPlatform.windows || + TargetPlatform.linux => true, + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => false, + }; + + Future _pick(WidgetRef ref) async { + final path = + await (pickFolderOverride?.call() ?? FilePicker.getDirectoryPath()); + if (path == null) return; + await ref + .read(universalImportNotifierProvider.notifier) + .resolvePhotosIn(path); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final state = ref.watch(universalImportNotifierProvider); + final pictureCount = state.payload + ?.entitiesOf(ImportEntityType.media) + .length; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.photo_library_outlined), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.importWizard_photos_foundCount(pictureCount ?? 0), + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 24), + if (!_canPickFolder) + Text(l10n.importWizard_photos_mobileUnsupported) + else if (state.isLoading) + Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text(l10n.importWizard_photos_scanning), + ], + ) + else ...[ + FilledButton.icon( + onPressed: () => _pick(ref), + icon: const Icon(Icons.folder_open), + label: Text(l10n.importWizard_photos_chooseFolder), + ), + if (state.photoFolderPath != null) ...[ + const SizedBox(height: 12), + Text( + state.photoFolderPath!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (state.photoResolution != null) ...[ + const SizedBox(height: 12), + Text( + l10n.importWizard_photos_matchSummary( + state.photoResolution!.matchedCount, + state.photoResolution!.filenameOnlyCount, + state.photoResolution!.notFoundCount, + ), + ), + ], + ], + const SizedBox(height: 24), + TextButton( + onPressed: () => + ref.read(universalImportNotifierProvider.notifier).skipPhotos(), + child: Text(l10n.importWizard_photos_skip), + ), + ], + ), + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 659186089c..e10362648e 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -5385,6 +5385,13 @@ "tags_empty": "لا توجد وسوم بعد. أنشئ وسوماً عند تعديل الغطسات.", "tags_hint_addMoreTags": "إضافة المزيد من الوسوم...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "الصور", + "importWizard_photos_foundCount": "{count, plural, one{صورة واحدة مشار إليها في هذا السجل} other{{count} صور مشار إليها في هذا السجل}}", + "importWizard_photos_chooseFolder": "اختر مجلد الصور...", + "importWizard_photos_scanning": "جارٍ فحص المجلد...", + "importWizard_photos_matchSummary": "{matched} مطابقة، {byName} بالاسم فقط، {missing} غير موجودة", + "importWizard_photos_skip": "تخطي الصور", + "importWizard_photos_mobileUnsupported": "يتطلب استيراد الصور مجلدًا على قرص هذا الجهاز. شغّل هذا الاستيراد على جهاز كمبيوتر لتضمينها. تُستورد الغطسات والمواقع بشكل طبيعي.", "importWizard_review_olderDivesSkipped": "{count, plural, one{تم تخطي غطسة واحدة أقدم — موجودة بالفعل في سجلك} other{تم تخطي {count} غطسات أقدم — موجودة بالفعل في سجلك}}", "tags_hint_addTags": "إضافة وسوم...", "tags_manage_bulkDeleteMessage": "سيتم إزالة هذه الوسوم من {diveCount, plural, =0{0 غوصات} =1{غوصة واحدة} other{{diveCount} غوصة}} إجمالاً. لا يمكن التراجع عن هذا الإجراء.", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index f9303e1df6..af868fff15 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -5385,6 +5385,13 @@ "tags_empty": "Noch keine Tags. Erstellen Sie Tags beim Bearbeiten von Tauchgängen.", "tags_hint_addMoreTags": "Weitere Tags hinzufügen...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Fotos", + "importWizard_photos_foundCount": "{count, plural, one{1 Foto in diesem Logbuch referenziert} other{{count} Fotos in diesem Logbuch referenziert}}", + "importWizard_photos_chooseFolder": "Fotoordner wählen...", + "importWizard_photos_scanning": "Ordner wird durchsucht...", + "importWizard_photos_matchSummary": "{matched} zugeordnet, {byName} nur über den Dateinamen, {missing} nicht gefunden", + "importWizard_photos_skip": "Fotos überspringen", + "importWizard_photos_mobileUnsupported": "Für den Fotoimport wird ein Ordner auf dem Speicher dieses Geräts benötigt. Führe diesen Import an einem Computer aus, um Fotos einzuschließen. Tauchgänge und Tauchplätze werden normal importiert.", "importWizard_review_olderDivesSkipped": "{count, plural, one{1 älterer Tauchgang übersprungen — bereits in deinem Logbuch} other{{count} ältere Tauchgänge übersprungen — bereits in deinem Logbuch}}", "tags_hint_addTags": "Tags hinzufügen...", "tags_manage_bulkDeleteMessage": "Diese Tags werden von insgesamt {diveCount, plural, =0{0 Tauchgängen} =1{1 Tauchgang} other{{diveCount} Tauchgängen}} entfernt. Dies kann nicht rückgängig gemacht werden.", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 0bda01382a..221c5dffd2 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -10880,6 +10880,50 @@ "tags_empty": "No tags yet. Create tags when editing dives.", "tags_hint_addMoreTags": "Add more tags...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Photos", + "@importWizard_photos_stepLabel": { + "description": "Wizard step label for resolving photos referenced by an imported logbook" + }, + "importWizard_photos_foundCount": "{count, plural, one{1 photo referenced in this logbook} other{{count} photos referenced in this logbook}}", + "@importWizard_photos_foundCount": { + "description": "Count of photos the imported logbook refers to", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importWizard_photos_chooseFolder": "Choose photo folder...", + "@importWizard_photos_chooseFolder": { + "description": "Button that opens a folder picker for locating referenced photos" + }, + "importWizard_photos_scanning": "Scanning folder...", + "@importWizard_photos_scanning": { + "description": "Progress label while the picked folder is being scanned" + }, + "importWizard_photos_matchSummary": "{matched} matched, {byName} by filename only, {missing} not found", + "@importWizard_photos_matchSummary": { + "description": "Result of resolving referenced photos against the picked folder", + "placeholders": { + "matched": { + "type": "int" + }, + "byName": { + "type": "int" + }, + "missing": { + "type": "int" + } + } + }, + "importWizard_photos_skip": "Skip photos", + "@importWizard_photos_skip": { + "description": "Button to continue the import without photos" + }, + "importWizard_photos_mobileUnsupported": "Importing photos needs a folder on this device's disk. Run this import on a computer to include them. Dives and sites import normally.", + "@importWizard_photos_mobileUnsupported": { + "description": "Shown on mobile, where a photo folder cannot be picked" + }, "importWizard_review_olderDivesSkipped": "{count, plural, one{1 older dive skipped — already in your log} other{{count} older dives skipped — already in your log}}", "@importWizard_review_olderDivesSkipped": { "description": "Title for the collapsed ExpansionTile summarizing auto-skipped dives that fall at or before the diver's first-sync cutoff", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 20c86b7ef2..16ffc6bacf 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -5385,6 +5385,13 @@ "tags_empty": "Aún no hay etiquetas. Crea etiquetas al editar inmersiones.", "tags_hint_addMoreTags": "Agregar más etiquetas...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Fotos", + "importWizard_photos_foundCount": "{count, plural, one{1 foto referenciada en este cuaderno} other{{count} fotos referenciadas en este cuaderno}}", + "importWizard_photos_chooseFolder": "Elegir carpeta de fotos...", + "importWizard_photos_scanning": "Explorando la carpeta...", + "importWizard_photos_matchSummary": "{matched} coincidencias, {byName} solo por nombre de archivo, {missing} no encontradas", + "importWizard_photos_skip": "Omitir fotos", + "importWizard_photos_mobileUnsupported": "Importar fotos requiere una carpeta en el disco de este dispositivo. Ejecuta esta importación en un ordenador para incluirlas. Las inmersiones y los puntos de buceo se importan con normalidad.", "importWizard_review_olderDivesSkipped": "{count, plural, one{1 inmersión antigua omitida — ya está en tu registro} other{{count} inmersiones antiguas omitidas — ya están en tu registro}}", "tags_hint_addTags": "Agregar etiquetas...", "tags_manage_bulkDeleteMessage": "Estas etiquetas se eliminaran de {diveCount, plural, =0{0 inmersiones} =1{1 inmersion} other{{diveCount} inmersiones}} en total. Esta accion no se puede deshacer.", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 891bb28d0c..b98410bdf1 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -5312,6 +5312,13 @@ "tags_empty": "Aucune étiquette pour le moment. Créez des étiquettes lors de la modification des plongées.", "tags_hint_addMoreTags": "Ajouter plus d'étiquettes...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Photos", + "importWizard_photos_foundCount": "{count, plural, one{1 photo référencée dans ce carnet} other{{count} photos référencées dans ce carnet}}", + "importWizard_photos_chooseFolder": "Choisir un dossier de photos...", + "importWizard_photos_scanning": "Analyse du dossier...", + "importWizard_photos_matchSummary": "{matched} associées, {byName} par nom de fichier uniquement, {missing} introuvables", + "importWizard_photos_skip": "Ignorer les photos", + "importWizard_photos_mobileUnsupported": "L'import de photos nécessite un dossier sur le disque de cet appareil. Lancez cet import sur un ordinateur pour les inclure. Les plongées et les sites s'importent normalement.", "importWizard_review_olderDivesSkipped": "{count, plural, one{1 plongée plus ancienne ignorée — déjà dans votre carnet} other{{count} plongées plus anciennes ignorées — déjà dans votre carnet}}", "tags_hint_addTags": "Ajouter des étiquettes...", "tags_manage_bulkDeleteMessage": "Ces etiquettes seront retirees de {diveCount, plural, =0{0 plongees} =1{1 plongee} other{{diveCount} plongees}} au total. Cette action est irreversible.", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 668b5a79db..37a90494b5 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -5385,6 +5385,13 @@ "tags_empty": "עדיין אין תגיות. צור תגיות בעת עריכת צלילות.", "tags_hint_addMoreTags": "הוסף תגיות נוספות...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "תמונות", + "importWizard_photos_foundCount": "{count, plural, one{תמונה אחת מוזכרת ביומן הזה} other{{count} תמונות מוזכרות ביומן הזה}}", + "importWizard_photos_chooseFolder": "בחר תיקיית תמונות...", + "importWizard_photos_scanning": "סורק את התיקייה...", + "importWizard_photos_matchSummary": "{matched} הותאמו, {byName} לפי שם קובץ בלבד, {missing} לא נמצאו", + "importWizard_photos_skip": "דלג על התמונות", + "importWizard_photos_mobileUnsupported": "ייבוא תמונות מחייב תיקייה בדיסק של המכשיר הזה. הרץ את הייבוא במחשב כדי לכלול אותן. צלילות ואתרים מיובאים כרגיל.", "importWizard_review_olderDivesSkipped": "{count, plural, one{צלילה ישנה אחת דולגה — כבר ביומן שלך} other{{count} צלילות ישנות דולגו — כבר ביומן שלך}}", "tags_hint_addTags": "הוסף תגיות...", "tags_manage_bulkDeleteMessage": "תגיות אלו יוסרו מ-{diveCount, plural, =0{0 צלילות} =1{צלילה אחת} other{{diveCount} צלילות}} בסך הכל. לא ניתן לבטל פעולה זו.", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index e82bab3f8a..907287c071 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -5312,6 +5312,13 @@ "tags_empty": "Még nincsenek címkék. Hozz létre címkéket a merülések szerkesztésekor.", "tags_hint_addMoreTags": "További címkék hozzáadása...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Fényképek", + "importWizard_photos_foundCount": "{count, plural, one{1 fénykép szerepel ebben a naplóban} other{{count} fénykép szerepel ebben a naplóban}}", + "importWizard_photos_chooseFolder": "Fényképmappa kiválasztása...", + "importWizard_photos_scanning": "Mappa vizsgálata...", + "importWizard_photos_matchSummary": "{matched} párosítva, {byName} csak fájlnév alapján, {missing} nem található", + "importWizard_photos_skip": "Fényképek kihagyása", + "importWizard_photos_mobileUnsupported": "A fényképek importálásához az eszköz lemezén lévő mappa szükséges. Futtasd ezt az importálást számítógépen, hogy a fényképek is bekerüljenek. A merülések és a merülőhelyek normálisan importálódnak.", "importWizard_review_olderDivesSkipped": "{count, plural, one{1 régebbi merülés kihagyva — már szerepel a naplódban} other{{count} régebbi merülés kihagyva — már szerepel a naplódban}}", "tags_hint_addTags": "Címkék hozzáadása...", "tags_manage_bulkDeleteMessage": "Ezek a címkék eltávolításra kerülnek összesen {diveCount, plural, =0{0 merülésből} =1{1 merülésből} other{{diveCount} merülésből}}. Ez nem vonható vissza.", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index f9197cab6f..20723e95c9 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -5308,6 +5308,13 @@ "tags_empty": "Nessun tag ancora. Crea tag quando modifichi le immersioni.", "tags_hint_addMoreTags": "Aggiungi altri tag...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Foto", + "importWizard_photos_foundCount": "{count, plural, one{1 foto referenziata in questo diario} other{{count} foto referenziate in questo diario}}", + "importWizard_photos_chooseFolder": "Scegli la cartella delle foto...", + "importWizard_photos_scanning": "Scansione della cartella...", + "importWizard_photos_matchSummary": "{matched} associate, {byName} solo per nome file, {missing} non trovate", + "importWizard_photos_skip": "Salta le foto", + "importWizard_photos_mobileUnsupported": "L'importazione delle foto richiede una cartella sul disco di questo dispositivo. Esegui questa importazione su un computer per includerle. Immersioni e siti vengono importati normalmente.", "importWizard_review_olderDivesSkipped": "{count, plural, one{1 immersione precedente ignorata — già nel tuo libro} other{{count} immersioni precedenti ignorate — già nel tuo libro}}", "tags_hint_addTags": "Aggiungi tag...", "tags_manage_bulkDeleteMessage": "Questi tag verranno rimossi da {diveCount, plural, =0{0 immersioni} =1{1 immersione} other{{diveCount} immersioni}} in totale. Questa azione non puo essere annullata.", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 6457188b76..ac73bed6ec 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -30082,6 +30082,48 @@ abstract class AppLocalizations { /// **'Tags'** String get importWizard_tagsLabel; + /// Wizard step label for resolving photos referenced by an imported logbook + /// + /// In en, this message translates to: + /// **'Photos'** + String get importWizard_photos_stepLabel; + + /// Count of photos the imported logbook refers to + /// + /// In en, this message translates to: + /// **'{count, plural, one{1 photo referenced in this logbook} other{{count} photos referenced in this logbook}}'** + String importWizard_photos_foundCount(int count); + + /// Button that opens a folder picker for locating referenced photos + /// + /// In en, this message translates to: + /// **'Choose photo folder...'** + String get importWizard_photos_chooseFolder; + + /// Progress label while the picked folder is being scanned + /// + /// In en, this message translates to: + /// **'Scanning folder...'** + String get importWizard_photos_scanning; + + /// Result of resolving referenced photos against the picked folder + /// + /// In en, this message translates to: + /// **'{matched} matched, {byName} by filename only, {missing} not found'** + String importWizard_photos_matchSummary(int matched, int byName, int missing); + + /// Button to continue the import without photos + /// + /// In en, this message translates to: + /// **'Skip photos'** + String get importWizard_photos_skip; + + /// Shown on mobile, where a photo folder cannot be picked + /// + /// In en, this message translates to: + /// **'Importing photos needs a folder on this device\'s disk. Run this import on a computer to include them. Dives and sites import normally.'** + String get importWizard_photos_mobileUnsupported; + /// Title for the collapsed ExpansionTile summarizing auto-skipped dives that fall at or before the diver's first-sync cutoff /// /// 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 bb38e42eee..32ddf69cbd 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -17669,6 +17669,42 @@ class AppLocalizationsAr extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'الصور'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count صور مشار إليها في هذا السجل', + one: 'صورة واحدة مشار إليها في هذا السجل', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'اختر مجلد الصور...'; + + @override + String get importWizard_photos_scanning => 'جارٍ فحص المجلد...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched مطابقة، $byName بالاسم فقط، $missing غير موجودة'; + } + + @override + String get importWizard_photos_skip => 'تخطي الصور'; + + @override + String get importWizard_photos_mobileUnsupported => + 'يتطلب استيراد الصور مجلدًا على قرص هذا الجهاز. شغّل هذا الاستيراد على جهاز كمبيوتر لتضمينها. تُستورد الغطسات والمواقع بشكل طبيعي.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 7eb1bd40c0..ae382d060a 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -17964,6 +17964,42 @@ class AppLocalizationsDe extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Fotos'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Fotos in diesem Logbuch referenziert', + one: '1 Foto in diesem Logbuch referenziert', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'Fotoordner wählen...'; + + @override + String get importWizard_photos_scanning => 'Ordner wird durchsucht...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched zugeordnet, $byName nur über den Dateinamen, $missing nicht gefunden'; + } + + @override + String get importWizard_photos_skip => 'Fotos überspringen'; + + @override + String get importWizard_photos_mobileUnsupported => + 'Für den Fotoimport wird ein Ordner auf dem Speicher dieses Geräts benötigt. Führe diesen Import an einem Computer aus, um Fotos einzuschließen. Tauchgänge und Tauchplätze werden normal importiert.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 1991f1bba2..2ba7369ef8 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -17687,6 +17687,42 @@ class AppLocalizationsEn extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Photos'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count photos referenced in this logbook', + one: '1 photo referenced in this logbook', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'Choose photo folder...'; + + @override + String get importWizard_photos_scanning => 'Scanning folder...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched matched, $byName by filename only, $missing not found'; + } + + @override + String get importWizard_photos_skip => 'Skip photos'; + + @override + String get importWizard_photos_mobileUnsupported => + 'Importing photos needs a folder on this device\'s disk. Run this import on a computer to include them. Dives and sites import normally.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index b116c894a0..b4576c7be5 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -18005,6 +18005,42 @@ class AppLocalizationsEs extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Fotos'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count fotos referenciadas en este cuaderno', + one: '1 foto referenciada en este cuaderno', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'Elegir carpeta de fotos...'; + + @override + String get importWizard_photos_scanning => 'Explorando la carpeta...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched coincidencias, $byName solo por nombre de archivo, $missing no encontradas'; + } + + @override + String get importWizard_photos_skip => 'Omitir fotos'; + + @override + String get importWizard_photos_mobileUnsupported => + 'Importar fotos requiere una carpeta en el disco de este dispositivo. Ejecuta esta importación en un ordenador para incluirlas. Las inmersiones y los puntos de buceo se importan con normalidad.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 57dbe079e1..0342495360 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -18064,6 +18064,43 @@ class AppLocalizationsFr extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Photos'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count photos référencées dans ce carnet', + one: '1 photo référencée dans ce carnet', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => + 'Choisir un dossier de photos...'; + + @override + String get importWizard_photos_scanning => 'Analyse du dossier...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched associées, $byName par nom de fichier uniquement, $missing introuvables'; + } + + @override + String get importWizard_photos_skip => 'Ignorer les photos'; + + @override + String get importWizard_photos_mobileUnsupported => + 'L\'import de photos nécessite un dossier sur le disque de cet appareil. Lancez cet import sur un ordinateur pour les inclure. Les plongées et les sites s\'importent normalement.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index bb2cdaa237..92fa99b923 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -17537,6 +17537,42 @@ class AppLocalizationsHe extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'תמונות'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count תמונות מוזכרות ביומן הזה', + one: 'תמונה אחת מוזכרת ביומן הזה', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'בחר תיקיית תמונות...'; + + @override + String get importWizard_photos_scanning => 'סורק את התיקייה...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched הותאמו, $byName לפי שם קובץ בלבד, $missing לא נמצאו'; + } + + @override + String get importWizard_photos_skip => 'דלג על התמונות'; + + @override + String get importWizard_photos_mobileUnsupported => + 'ייבוא תמונות מחייב תיקייה בדיסק של המכשיר הזה. הרץ את הייבוא במחשב כדי לכלול אותן. צלילות ואתרים מיובאים כרגיל.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index ce9f1b1798..5ce09a417c 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -17943,6 +17943,42 @@ class AppLocalizationsHu extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Fényképek'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count fénykép szerepel ebben a naplóban', + one: '1 fénykép szerepel ebben a naplóban', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'Fényképmappa kiválasztása...'; + + @override + String get importWizard_photos_scanning => 'Mappa vizsgálata...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched párosítva, $byName csak fájlnév alapján, $missing nem található'; + } + + @override + String get importWizard_photos_skip => 'Fényképek kihagyása'; + + @override + String get importWizard_photos_mobileUnsupported => + 'A fényképek importálásához az eszköz lemezén lévő mappa szükséges. Futtasd ezt az importálást számítógépen, hogy a fényképek is bekerüljenek. A merülések és a merülőhelyek normálisan importálódnak.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index c22c81ec05..0534372355 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -17999,6 +17999,43 @@ class AppLocalizationsIt extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Foto'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count foto referenziate in questo diario', + one: '1 foto referenziata in questo diario', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => + 'Scegli la cartella delle foto...'; + + @override + String get importWizard_photos_scanning => 'Scansione della cartella...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched associate, $byName solo per nome file, $missing non trovate'; + } + + @override + String get importWizard_photos_skip => 'Salta le foto'; + + @override + String get importWizard_photos_mobileUnsupported => + 'L\'importazione delle foto richiede una cartella sul disco di questo dispositivo. Esegui questa importazione su un computer per includerle. Immersioni e siti vengono importati normalmente.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index e051717524..b7474e1120 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -17852,6 +17852,42 @@ class AppLocalizationsNl extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Foto\'s'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count foto\'s waarnaar dit logboek verwijst', + one: '1 foto waarnaar dit logboek verwijst', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'Fotomap kiezen...'; + + @override + String get importWizard_photos_scanning => 'Map wordt gescand...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched gekoppeld, $byName alleen op bestandsnaam, $missing niet gevonden'; + } + + @override + String get importWizard_photos_skip => 'Foto\'s overslaan'; + + @override + String get importWizard_photos_mobileUnsupported => + 'Voor het importeren van foto\'s is een map op de schijf van dit apparaat nodig. Voer deze import uit op een computer om ze mee te nemen. Duiken en duikstekken worden normaal geïmporteerd.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index d6e70f4ad1..0b0ac90de3 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -18005,6 +18005,42 @@ class AppLocalizationsPt extends AppLocalizations { @override String get importWizard_tagsLabel => 'Tags'; + @override + String get importWizard_photos_stepLabel => 'Fotos'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count fotos referenciadas neste diário', + one: '1 foto referenciada neste diário', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => 'Escolher pasta de fotos...'; + + @override + String get importWizard_photos_scanning => 'A analisar a pasta...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '$matched correspondidas, $byName apenas pelo nome do ficheiro, $missing não encontradas'; + } + + @override + String get importWizard_photos_skip => 'Ignorar fotos'; + + @override + String get importWizard_photos_mobileUnsupported => + 'Importar fotos requer uma pasta no disco deste dispositivo. Execute esta importação num computador para as incluir. Os mergulhos e locais são importados normalmente.'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 46d696423e..24538117bf 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -17076,6 +17076,41 @@ class AppLocalizationsZh extends AppLocalizations { @override String get importWizard_tagsLabel => '标签'; + @override + String get importWizard_photos_stepLabel => '照片'; + + @override + String importWizard_photos_foundCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '此日志引用了 $count 张照片', + ); + return '$_temp0'; + } + + @override + String get importWizard_photos_chooseFolder => '选择照片文件夹...'; + + @override + String get importWizard_photos_scanning => '正在扫描文件夹...'; + + @override + String importWizard_photos_matchSummary( + int matched, + int byName, + int missing, + ) { + return '已匹配 $matched 张,仅按文件名匹配 $byName 张,未找到 $missing 张'; + } + + @override + String get importWizard_photos_skip => '跳过照片'; + + @override + String get importWizard_photos_mobileUnsupported => + '导入照片需要此设备磁盘上的文件夹。请在电脑上运行此导入以包含照片。潜水记录和潜点会正常导入。'; + @override String importWizard_review_olderDivesSkipped(int count) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index d371acde94..e8c092b2fc 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -5385,6 +5385,13 @@ "tags_empty": "Nog geen tags. Maak tags aan bij het bewerken van duiken.", "tags_hint_addMoreTags": "Meer tags toevoegen...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Foto's", + "importWizard_photos_foundCount": "{count, plural, one{1 foto waarnaar dit logboek verwijst} other{{count} foto's waarnaar dit logboek verwijst}}", + "importWizard_photos_chooseFolder": "Fotomap kiezen...", + "importWizard_photos_scanning": "Map wordt gescand...", + "importWizard_photos_matchSummary": "{matched} gekoppeld, {byName} alleen op bestandsnaam, {missing} niet gevonden", + "importWizard_photos_skip": "Foto's overslaan", + "importWizard_photos_mobileUnsupported": "Voor het importeren van foto's is een map op de schijf van dit apparaat nodig. Voer deze import uit op een computer om ze mee te nemen. Duiken en duikstekken worden normaal geïmporteerd.", "importWizard_review_olderDivesSkipped": "{count, plural, one{1 oudere duik overgeslagen — al in je logboek} other{{count} oudere duiken overgeslagen — al in je logboek}}", "tags_hint_addTags": "Tags toevoegen...", "tags_manage_bulkDeleteMessage": "Deze tags worden verwijderd van in totaal {diveCount, plural, =0{0 duiken} =1{1 duik} other{{diveCount} duiken}}. Dit kan niet ongedaan worden gemaakt.", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index b3cada17ab..97fa1ce627 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -5385,6 +5385,13 @@ "tags_empty": "Nenhuma tag ainda. Crie tags ao editar mergulhos.", "tags_hint_addMoreTags": "Adicionar mais tags...", "importWizard_tagsLabel": "Tags", + "importWizard_photos_stepLabel": "Fotos", + "importWizard_photos_foundCount": "{count, plural, one{1 foto referenciada neste diário} other{{count} fotos referenciadas neste diário}}", + "importWizard_photos_chooseFolder": "Escolher pasta de fotos...", + "importWizard_photos_scanning": "A analisar a pasta...", + "importWizard_photos_matchSummary": "{matched} correspondidas, {byName} apenas pelo nome do ficheiro, {missing} não encontradas", + "importWizard_photos_skip": "Ignorar fotos", + "importWizard_photos_mobileUnsupported": "Importar fotos requer uma pasta no disco deste dispositivo. Execute esta importação num computador para as incluir. Os mergulhos e locais são importados normalmente.", "importWizard_review_olderDivesSkipped": "{count, plural, one{1 mergulho mais antigo ignorado — já está no seu registo} other{{count} mergulhos mais antigos ignorados — já estão no seu registo}}", "tags_hint_addTags": "Adicionar tags...", "tags_manage_bulkDeleteMessage": "Estas etiquetas serao removidas de {diveCount, plural, =0{0 mergulhos} =1{1 mergulho} other{{diveCount} mergulhos}} no total. Esta acao nao pode ser desfeita.", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 699b8aac5a..be49326313 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -4014,6 +4014,13 @@ "gas_tmx2135_description": "常氧三混气 21/35", "gas_tmx2135_displayName": "Tx 21/35", "importWizard_tagsLabel": "标签", + "importWizard_photos_stepLabel": "照片", + "importWizard_photos_foundCount": "{count, plural, other{此日志引用了 {count} 张照片}}", + "importWizard_photos_chooseFolder": "选择照片文件夹...", + "importWizard_photos_scanning": "正在扫描文件夹...", + "importWizard_photos_matchSummary": "已匹配 {matched} 张,仅按文件名匹配 {byName} 张,未找到 {missing} 张", + "importWizard_photos_skip": "跳过照片", + "importWizard_photos_mobileUnsupported": "导入照片需要此设备磁盘上的文件夹。请在电脑上运行此导入以包含照片。潜水记录和潜点会正常导入。", "importWizard_review_olderDivesSkipped": "{count, plural, other{已跳过 {count} 次较早的潜水 — 已在您的日志中}}", "maps_compass_resetLabel": "将地图方向重置为正北", "maps_compass_resetTooltip": "正北朝上", diff --git a/test/features/dive_log/presentation/pages/dive_edit_prefill_test.dart b/test/features/dive_log/presentation/pages/dive_edit_prefill_test.dart index 18fc436aae..fb21876a15 100644 --- a/test/features/dive_log/presentation/pages/dive_edit_prefill_test.dart +++ b/test/features/dive_log/presentation/pages/dive_edit_prefill_test.dart @@ -30,6 +30,9 @@ class _RecordingMediaImportService implements MediaImportService { required File sourceFile, required String diveId, DateTime? takenAt, + double? latitude, + double? longitude, + String subdirectory = 'scanned_logs', }) async { localFileCalls++; if (shouldThrow) { diff --git a/test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart b/test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart new file mode 100644 index 0000000000..3d4b31fe60 --- /dev/null +++ b/test/features/import_wizard/presentation/widgets/photo_folder_step_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/foundation.dart' + show debugDefaultTargetPlatformOverride; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/import_wizard/presentation/widgets/photo_folder_step.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/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/domain/services/import_media_resolver.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// These tests never let the resolver touch the filesystem. Its real IO does +/// not progress inside testWidgets' fake-async zone, and driving a tap from +/// inside `runAsync` to work around that deadlocks the binding. Resolution is +/// covered end to end by import_media_resolver_test.dart; here the resolution +/// is seeded directly so the widget's rendering is what is under test. +void main() { + late ProviderContainer container; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + }); + + tearDown(() { + container.dispose(); + }); + + /// Sets the platform for [body] and always clears it again. + /// + /// The binding asserts every foundation debug variable is unset at the end + /// of the TEST BODY, before tearDown runs, so clearing it in tearDown is + /// too late. The finally also keeps one failing expectation from cascading + /// into every later test in the file. + Future withPlatform( + TargetPlatform platform, + Future Function() body, + ) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + + void seedPictures(int count) { + final notifier = container.read(universalImportNotifierProvider.notifier); + notifier.state = notifier.state.copyWith( + payload: ImportPayload( + entities: { + ImportEntityType.dives: [ + {'uddfId': 'd0', 'dateTime': DateTime(2025, 1, 15)}, + ], + ImportEntityType.media: [ + for (var i = 0; i < count; i++) + {'filename': '/home/jai/Pictures/p$i.jpg', '_diveIndex': 0}, + ], + }, + ), + ); + } + + Widget host(Widget child) { + return UncontrolledProviderScope( + container: container, + child: MaterialApp( + // Pin the locale: an unpinned host adopts the test device locale and + // the string assertions below stop matching. + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ), + ); + } + + testWidgets('shows the referenced count and a folder button', (tester) async { + await withPlatform(TargetPlatform.macOS, () async { + seedPictures(1); + + await tester.pumpWidget(host(const PhotoFolderStep())); + await tester.pump(); + + expect(find.text('1 photo referenced in this logbook'), findsOneWidget); + expect(find.text('Choose photo folder...'), findsOneWidget); + }); + }); + + testWidgets('pluralises the referenced count', (tester) async { + await withPlatform(TargetPlatform.macOS, () async { + seedPictures(2); + + await tester.pumpWidget(host(const PhotoFolderStep())); + await tester.pump(); + + expect(find.text('2 photos referenced in this logbook'), findsOneWidget); + }); + }); + + testWidgets('renders the match summary and the picked folder', ( + tester, + ) async { + await withPlatform(TargetPlatform.macOS, () async { + seedPictures(5); + final notifier = container.read(universalImportNotifierProvider.notifier); + notifier.state = notifier.state.copyWith( + photoFolderPath: '/Users/eric/Photos', + photoResolution: const ImportMediaResolution( + resolvedPathByIndex: {0: '/a', 1: '/b', 2: '/c'}, + reRootedCount: 2, + filenameOnlyCount: 1, + notFoundCount: 2, + ), + ); + + await tester.pumpWidget(host(const PhotoFolderStep())); + await tester.pump(); + + expect( + find.text('3 matched, 1 by filename only, 2 not found'), + findsOneWidget, + ); + expect(find.text('/Users/eric/Photos'), findsOneWidget); + }); + }); + + testWidgets('a cancelled folder pick leaves the step untouched', ( + tester, + ) async { + await withPlatform(TargetPlatform.macOS, () async { + seedPictures(1); + + await tester.pumpWidget( + host(PhotoFolderStep(pickFolderOverride: () async => null)), + ); + await tester.pump(); + + await tester.tap(find.text('Choose photo folder...')); + await tester.pump(); + + expect(find.text('Choose photo folder...'), findsOneWidget); + expect( + container.read(universalImportNotifierProvider).photoResolution, + isNull, + ); + }); + }); + + testWidgets('offers to skip photos and records the choice', (tester) async { + await withPlatform(TargetPlatform.macOS, () async { + seedPictures(1); + + await tester.pumpWidget(host(const PhotoFolderStep())); + await tester.pump(); + + await tester.tap(find.text('Skip photos')); + await tester.pump(); + + expect( + container.read(universalImportNotifierProvider).photosSkipped, + isTrue, + ); + }); + }); + + testWidgets('explains the limitation instead of picking on mobile', ( + tester, + ) async { + await withPlatform(TargetPlatform.android, () async { + seedPictures(1); + + await tester.pumpWidget(host(const PhotoFolderStep())); + await tester.pump(); + + expect(find.text('Choose photo folder...'), findsNothing); + expect( + find.textContaining('Run this import on a computer'), + findsOneWidget, + ); + // The count is still stated, so nothing is silently dropped. + expect(find.text('1 photo referenced in this logbook'), findsOneWidget); + }); + }); +} From 1b1c32d3c8abc6846d9eb2a8d3ecbdae1f5fa1a7 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:03:49 -0400 Subject: [PATCH 082/122] feat(import): attach Subsurface-referenced photos to their dives Registers the Photos step, shows the photos in review, and writes each resolved file against the dive that referenced it, carrying the picture's own offset and coordinates. A copy failure is counted and reported rather than swallowed, so a photo that could not be written is visible in the summary instead of silently missing. Closes #1147 --- .../data/adapters/universal_adapter.dart | 129 +++++++++++++- .../services/import_media_resolver.dart | 3 - .../universal_adapter_photo_test.dart | 161 ++++++++++++++++++ .../data/adapters/universal_adapter_test.dart | 7 +- .../domain/models/import_bundle_test.dart | 5 +- .../data/models/import_enums_test.dart | 3 +- 6 files changed, 298 insertions(+), 10 deletions(-) diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index a4d0d5fd95..2f861e2dab 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -15,6 +15,7 @@ import 'package:submersion/features/courses/presentation/providers/course_provid import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; +import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; import 'package:submersion/features/data_quality/data/services/quality_scan_service.dart'; @@ -52,6 +53,8 @@ import 'package:submersion/features/universal_import/data/models/picked_import_f import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; import 'package:submersion/features/universal_import/presentation/providers/import_consolidation_service.dart' show performConsolidations; +import 'package:submersion/features/import_wizard/presentation/widgets/photo_folder_step.dart'; +import 'package:submersion/features/universal_import/domain/services/import_media_resolver.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; import 'package:submersion/features/universal_import/presentation/widgets/field_mapping_step.dart'; import 'package:submersion/features/universal_import/presentation/widgets/file_selection_step.dart'; @@ -123,6 +126,8 @@ final universalAdapterPhotosReadyProvider = Provider((ref) { /// UDDF, auto-detected formats). Wraps [UniversalImportNotifier] into the /// unified import wizard framework. class UniversalAdapter implements ImportSourceAdapter { + static const _log = LoggerService('UniversalAdapter'); + UniversalAdapter({required WidgetRef ref, String displayName = 'File Import'}) : _ref = ref, _displayName = displayName; @@ -226,6 +231,17 @@ class UniversalAdapter implements ImportSourceAdapter { await notifier.confirmFieldMapping(); }, ), + WizardStepDef( + label: 'Photos', + icon: Icons.photo_library_outlined, + builder: (context) => const PhotoFolderStep(), + canAdvance: universalAdapterPhotosReadyProvider, + // Stricter than canAdvance on purpose: the step auto-skips only when + // the logbook references no photos at all, never past a decision the + // user has not made. + canAutoAdvance: universalAdapterNoPhotosProvider, + autoAdvance: true, + ), ]; @override @@ -310,6 +326,12 @@ class UniversalAdapter implements ImportSourceAdapter { payload.entitiesOf(ui.ImportEntityType.courses), _courseToEntityItem, ); + _addGroupIfNotEmpty( + groups, + wizard.ImportEntityType.media, + payload.entitiesOf(ui.ImportEntityType.media), + _mediaToEntityItem, + ); return ImportBundle( source: ImportSourceInfo( @@ -616,6 +638,33 @@ class UniversalAdapter implements ImportSourceAdapter { }, ); + // Attach photos the logbook referenced by absolute path, resolved against + // the folder picked in the Photos step. This and the ZIP path above cover + // different sources and cannot double-count: a ZIP sidecar and a + // reference never describe the same file. + final resolution = notifierState.photoResolution; + final resolvedPhotos = resolution == null + ? 0 + : await attachResolvedPhotos( + media: payload.entitiesOf(ui.ImportEntityType.media), + resolvedPathByIndex: resolution.resolvedPathByIndex, + diveIdByIndex: result.diveIdByIndex, + removedDiveIds: removedDiveIds, + dives: payload.entitiesOf(ui.ImportEntityType.dives), + attach: (file, diveId, takenAt, latitude, longitude) async { + await _ref + .read(mediaImportServiceProvider) + .importLocalFileForDive( + sourceFile: file, + diveId: diveId, + takenAt: takenAt, + latitude: latitude, + longitude: longitude, + subdirectory: 'imported_photos', + ); + }, + ); + // `importer.import` counted folded/removed dives as imported; subtract only // the dives that were ACTUALLY removed (folded, or compensating-deleted). // A dive whose fold AND cleanup both failed is still standalone in the DB, @@ -684,8 +733,9 @@ class UniversalAdapter implements ImportSourceAdapter { skippedCount: skipped + cleanedUpFailures, importedDiveIds: netImportedDiveIds, fileOutcomes: fileOutcomes, - attachedPhotoCount: attachedPhotos, - unmatchedPhotoCount: notifierState.unmatchedPhotoCount, + attachedPhotoCount: attachedPhotos + resolvedPhotos, + unmatchedPhotoCount: + notifierState.unmatchedPhotoCount + (resolution?.notFoundCount ?? 0), ); } @@ -879,6 +929,13 @@ class UniversalAdapter implements ImportSourceAdapter { return EntityItem(title: name, subtitle: ''); } + EntityItem _mediaToEntityItem(Map data) { + final filename = (data['filename'] as String?) ?? ''; + // The foreign path may use either separator, so basename it accordingly. + final base = filename.isEmpty ? 'Unnamed' : foreignBasename(filename); + return EntityItem(title: base, subtitle: filename); + } + EntityItem _courseToEntityItem(Map data) { final name = (data['name'] as String?) ?? 'Unnamed'; final agency = data['agency'] as String?; @@ -955,6 +1012,74 @@ class UniversalAdapter implements ImportSourceAdapter { return attachedCount; } + /// Attaches resolved photos to the dives that survived import. + /// + /// Each payload media entry names its dive by `_diveIndex`, so unlike + /// [attachImportedPhotos] this needs no one-dive-per-file rule: a + /// multi-dive logbook attaches each photo to exactly the dive that + /// referenced it. + /// + /// A copy failure is counted and skipped rather than thrown: the dive + /// import has already succeeded and must not be undone by a photo. Unlike + /// [attachImportedPhotos] the failure is not silent, because the caller + /// reports the shortfall against the resolved count. + /// + /// Returns the number of photos actually attached. + static Future attachResolvedPhotos({ + required List> media, + required Map resolvedPathByIndex, + required Map diveIdByIndex, + required Set removedDiveIds, + required List> dives, + required Future Function( + File file, + String diveId, + DateTime? takenAt, + double? latitude, + double? longitude, + ) + attach, + }) async { + var attachedCount = 0; + + for (final entry in resolvedPathByIndex.entries) { + final mediaIndex = entry.key; + if (mediaIndex < 0 || mediaIndex >= media.length) continue; + final picture = media[mediaIndex]; + + final diveIndex = picture['_diveIndex']; + if (diveIndex is! int) continue; + final diveId = diveIdByIndex[diveIndex]; + if (diveId == null || removedDiveIds.contains(diveId)) continue; + + DateTime? takenAt; + if (diveIndex >= 0 && diveIndex < dives.length) { + final start = dives[diveIndex]['dateTime'] as DateTime?; + final offsetSeconds = picture['offsetSeconds']; + takenAt = start == null + ? null + : (offsetSeconds is int + ? start.add(Duration(seconds: offsetSeconds)) + : start); + } + + try { + await attach( + File(entry.value), + diveId, + takenAt, + asDoubleOrNull(picture['latitude']), + asDoubleOrNull(picture['longitude']), + ); + attachedCount++; + } catch (e) { + _log.warning('Failed to attach imported photo ${entry.value}: $e'); + } + } + + return attachedCount; + } + // --------------------------------------------------------------------------- // Helpers — duplicate application // --------------------------------------------------------------------------- diff --git a/lib/features/universal_import/domain/services/import_media_resolver.dart b/lib/features/universal_import/domain/services/import_media_resolver.dart index 891e82c7db..3800f5facd 100644 --- a/lib/features/universal_import/domain/services/import_media_resolver.dart +++ b/lib/features/universal_import/domain/services/import_media_resolver.dart @@ -1,5 +1,3 @@ -import 'package:flutter/foundation.dart' show visibleForTesting; - import 'package:submersion/features/media/data/services/repair/folder_candidate_source.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; @@ -157,7 +155,6 @@ class ImportMediaResolver { /// separators are therefore treated as separators, which is safe in practice /// because a photo filename containing a literal backslash is vanishingly /// rare next to the certainty of Windows-exported logbooks. -@visibleForTesting String foreignBasename(String path) { final index = path.lastIndexOf(RegExp(r'[/\\]')); return index < 0 ? path : path.substring(index + 1); diff --git a/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart b/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart index 1858a1f279..2e64886e5b 100644 --- a/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart +++ b/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart @@ -122,4 +122,165 @@ void main() { ); expect(count, 0); }); + group('attachResolvedPhotos', () { + test('attaches each resolved photo to its own dive', () async { + final attached = <({String path, String diveId, DateTime? takenAt})>[]; + + final count = await UniversalAdapter.attachResolvedPhotos( + media: [ + { + 'filename': '/home/jai/Pictures/a.jpg', + 'offsetSeconds': 200, + '_diveIndex': 0, + }, + { + 'filename': '/home/jai/Pictures/b.jpg', + 'offsetSeconds': null, + '_diveIndex': 1, + }, + ], + resolvedPathByIndex: const { + 0: '/Users/eric/Photos/a.jpg', + 1: '/Users/eric/Photos/b.jpg', + }, + diveIdByIndex: const {0: 'dive-a', 1: 'dive-b'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + {'dateTime': DateTime.utc(2025, 1, 16, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + attached.add((path: file.path, diveId: diveId, takenAt: takenAt)); + }, + ); + + expect(count, 2); + expect(attached, hasLength(2)); + final byDive = {for (final a in attached) a.diveId: a}; + // Dive start plus the 3:20 offset. + expect(byDive['dive-a']!.takenAt, DateTime.utc(2025, 1, 15, 10, 3, 20)); + // No offset: falls back to the dive's own start. + expect(byDive['dive-b']!.takenAt, DateTime.utc(2025, 1, 16, 10)); + }); + + test('applies a negative offset before the dive start', () async { + DateTime? seen; + + await UniversalAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': -65, '_diveIndex': 0}, + ], + resolvedPathByIndex: const {0: '/x/a.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + seen = takenAt; + }, + ); + + expect(seen, DateTime.utc(2025, 1, 15, 9, 58, 55)); + }); + + test('drops photos whose dive was folded away by consolidation', () async { + var attachCalls = 0; + + final count = await UniversalAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + ], + resolvedPathByIndex: const {0: '/Users/eric/Photos/a.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {'dive-a'}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + attachCalls++; + }, + ); + + expect(count, 0); + expect(attachCalls, 0); + }); + + test('counts a failed copy without failing the import', () async { + final count = await UniversalAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + {'filename': '/p/b.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + ], + resolvedPathByIndex: const {0: '/x/a.jpg', 1: '/x/b.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + if (file.path.endsWith('b.jpg')) { + throw const FileSystemException('copy failed'); + } + }, + ); + + expect(count, 1); + }); + + test('passes the picture coordinates through', () async { + double? seenLatitude; + double? seenLongitude; + + await UniversalAdapter.attachResolvedPhotos( + media: [ + { + 'filename': '/p/a.jpg', + 'offsetSeconds': 0, + 'latitude': 18.465562, + 'longitude': -66.084902, + '_diveIndex': 0, + }, + ], + resolvedPathByIndex: const {0: '/x/a.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + seenLatitude = latitude; + seenLongitude = longitude; + }, + ); + + expect(seenLatitude, closeTo(18.465562, 1e-6)); + expect(seenLongitude, closeTo(-66.084902, 1e-6)); + }); + + test( + 'ignores a picture whose dive never made it into the import', + () async { + var attachCalls = 0; + + final count = await UniversalAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': 0, '_diveIndex': 7}, + ], + resolvedPathByIndex: const {0: '/x/a.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + attachCalls++; + }, + ); + + expect(count, 0); + expect(attachCalls, 0); + }, + ); + }); } diff --git a/test/features/import_wizard/data/adapters/universal_adapter_test.dart b/test/features/import_wizard/data/adapters/universal_adapter_test.dart index 8952a232f6..2736f0fd5c 100644 --- a/test/features/import_wizard/data/adapters/universal_adapter_test.dart +++ b/test/features/import_wizard/data/adapters/universal_adapter_test.dart @@ -436,12 +436,15 @@ void main() { ); }); - testWidgets('acquisitionSteps has three steps', (tester) async { + testWidgets('acquisitionSteps has four steps', (tester) async { await _runWithAdapter( tester, overrides: _buildBundleOverrides(), callback: (adapter) async { - expect(adapter.acquisitionSteps, hasLength(3)); + // Select File, Confirm Source, Map Fields, Photos. The Photos step + // auto-advances away when the payload references no photos. + expect(adapter.acquisitionSteps, hasLength(4)); + expect(adapter.acquisitionSteps.last.label, 'Photos'); }, ); }); diff --git a/test/features/import_wizard/domain/models/import_bundle_test.dart b/test/features/import_wizard/domain/models/import_bundle_test.dart index e5c89b7f50..006f142971 100644 --- a/test/features/import_wizard/domain/models/import_bundle_test.dart +++ b/test/features/import_wizard/domain/models/import_bundle_test.dart @@ -17,8 +17,8 @@ void main() { }); group('ImportEntityType', () { - test('has all 11 expected values', () { - expect(ImportEntityType.values, hasLength(11)); + test('has all 12 expected values', () { + expect(ImportEntityType.values, hasLength(12)); expect(ImportEntityType.values, contains(ImportEntityType.dives)); expect(ImportEntityType.values, contains(ImportEntityType.sites)); expect(ImportEntityType.values, contains(ImportEntityType.buddies)); @@ -33,6 +33,7 @@ void main() { expect(ImportEntityType.values, contains(ImportEntityType.diveTypes)); expect(ImportEntityType.values, contains(ImportEntityType.equipmentSets)); expect(ImportEntityType.values, contains(ImportEntityType.courses)); + expect(ImportEntityType.values, contains(ImportEntityType.media)); }); }); diff --git a/test/features/universal_import/data/models/import_enums_test.dart b/test/features/universal_import/data/models/import_enums_test.dart index f5396d650e..a9803c373d 100644 --- a/test/features/universal_import/data/models/import_enums_test.dart +++ b/test/features/universal_import/data/models/import_enums_test.dart @@ -101,7 +101,7 @@ void main() { group('ImportEntityType', () { test('has all expected values', () { - expect(ImportEntityType.values, hasLength(12)); + expect(ImportEntityType.values, hasLength(13)); }); test('displayName for each entity type', () { @@ -113,6 +113,7 @@ void main() { expect(ImportEntityType.buddies.displayName, 'Buddies'); expect(ImportEntityType.diveCenters.displayName, 'Dive Centers'); expect(ImportEntityType.certifications.displayName, 'Certifications'); + expect(ImportEntityType.media.displayName, 'Photos'); expect(ImportEntityType.courses.displayName, 'Courses'); expect(ImportEntityType.tags.displayName, 'Tags'); expect(ImportEntityType.diveTypes.displayName, 'Dive Types'); From 03cb2f63e50428182926dd4fe888347d760acf5d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:05:29 -0400 Subject: [PATCH 083/122] test(import): cover parse to resolve on a realistic Subsurface logbook A trip-wrapped dive and a standalone dive, a moved photo library, a deleted photo, and a negative offset, in one pass. Refs #1147 --- .../subsurface_picture_e2e_test.dart | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test/features/universal_import/subsurface_picture_e2e_test.dart diff --git a/test/features/universal_import/subsurface_picture_e2e_test.dart b/test/features/universal_import/subsurface_picture_e2e_test.dart new file mode 100644 index 0000000000..3a444c6489 --- /dev/null +++ b/test/features/universal_import/subsurface_picture_e2e_test.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/parsers/subsurface_xml_parser.dart'; +import 'package:submersion/features/universal_import/domain/services/import_media_resolver.dart'; + +/// Parse -> resolve, on a logbook shaped like a real Subsurface export. +void main() { + test( + 'a multi-dive logbook resolves its photos against a moved library', + () async { + final root = await Directory.systemTemp.createTemp('e2e_photos_'); + addTearDown(() async { + if (root.existsSync()) await root.delete(recursive: true); + }); + + // The library moved from /home/jai/Pictures to this root, keeping shape. + for (final rel in [ + p.join('2025', 'dive042.jpg'), + p.join('2025', 'dive043.jpg'), + p.join('2024', 'wreck.jpg'), + ]) { + final f = File(p.join(root.path, rel)); + await f.parent.create(recursive: true); + await f.writeAsString('bytes'); + } + + const xml = ''' + + + + + + + + + + + + + + +'''; + + final payload = await SubsurfaceXmlParser().parse( + Uint8List.fromList(utf8.encode(xml)), + ); + + final dives = payload.entitiesOf(ImportEntityType.dives); + final media = payload.entitiesOf(ImportEntityType.media); + expect(dives, hasLength(2)); + expect(media, hasLength(4)); + + final resolution = await const ImportMediaResolver().resolve( + media: media, + rootPath: root.path, + ); + + // Three of four resolve; the deleted one is reported, not dropped. + expect(resolution.matchedCount, 3); + expect(resolution.notFoundCount, 1); + expect(resolution.reRootedCount, 3); + + // Each resolved photo points at the dive that referenced it. + for (final entry in resolution.resolvedPathByIndex.entries) { + final diveIndex = media[entry.key]['_diveIndex'] as int; + final expectedYear = diveIndex == 0 ? '2025' : '2024'; + expect(entry.value, contains(expectedYear)); + } + + // The gps attribute survives only on the picture that carried one. + expect(media[0]['latitude'], closeTo(18.465562, 1e-6)); + expect(media[1]['latitude'], isNull); + + // takenAt maths, as the adapter will compute it. + final start = dives[0]['dateTime'] as DateTime; + final offset = media[0]['offsetSeconds'] as int; + expect( + start.add(Duration(seconds: offset)).difference(start).inSeconds, + 200, + ); + + // A negative offset stays negative. + expect(media[2]['offsetSeconds'], -30); + }, + ); +} From 86d6139d52514acd237a94cf1d1706eeaef2fd33 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:06:30 -0400 Subject: [PATCH 084/122] chore(import): restore CRLF line endings on two touched files Both files are CRLF in the repo; an editing pass had normalised them to LF, turning a one-line change into a whole-file diff. Refs #1147 --- .../data/models/import_enums.dart | 614 +++++++++--------- .../widgets/import_summary_step.dart | 354 +++++----- 2 files changed, 484 insertions(+), 484 deletions(-) diff --git a/lib/features/universal_import/data/models/import_enums.dart b/lib/features/universal_import/data/models/import_enums.dart index 75f3bd3173..d317393c3b 100644 --- a/lib/features/universal_import/data/models/import_enums.dart +++ b/lib/features/universal_import/data/models/import_enums.dart @@ -1,307 +1,307 @@ -/// File format types that can be detected by the universal import wizard. -enum ImportFormat { - csv, - uddf, - macdiveXml, - macdiveSqlite, - subsurfaceXml, - divingLogXml, - suuntoSml, - suuntoDm5, - fit, - shearwaterDb, - scubapro, - danDl7, - ratioXml, - sqlite, - unknown; - - String get displayName => switch (this) { - csv => 'CSV', - uddf => 'UDDF', - macdiveXml => 'MacDive XML', - macdiveSqlite => 'MacDive SQLite', - subsurfaceXml => 'Subsurface XML', - divingLogXml => 'Diving Log XML', - suuntoSml => 'Suunto SML', - suuntoDm5 => 'Suunto DM5', - fit => 'Garmin FIT', - shearwaterDb => 'Shearwater Cloud', - scubapro => 'Scubapro', - danDl7 => 'DAN DL7', - ratioXml => 'Ratio XML', - sqlite => 'SQLite Database', - unknown => 'Unknown', - }; - - /// Whether this format has a parser implemented in v1.5. - bool get isSupported => switch (this) { - csv || - uddf || - subsurfaceXml || - fit || - shearwaterDb || - macdiveXml || - macdiveSqlite || - danDl7 || - ratioXml => true, - _ => false, - }; -} - -/// Source applications that export dive data. -enum SourceApp { - submersion, - subsurface, - macdive, - divingLog, - diveMate, - shearwater, - suunto, - garminConnect, - scubapro, - ssiMyDiveGuide, - dan, - diverLog, - ratio, - generic; - - String get displayName => switch (this) { - submersion => 'Submersion', - subsurface => 'Subsurface', - macdive => 'MacDive', - divingLog => 'Diving Log', - diveMate => 'DiveMate', - shearwater => 'Shearwater', - suunto => 'Suunto', - garminConnect => 'Garmin Connect', - scubapro => 'Scubapro', - ssiMyDiveGuide => 'SSI MyDiveGuide', - dan => 'DAN', - diverLog => 'DiverLog+', - ratio => 'Ratio Computers', - generic => 'Unknown App', - }; - - /// Instructions for exporting from this app in a supported format. - String? get exportInstructions => switch (this) { - shearwater => null, // Native .db import supported - ratio => null, // Native XML import supported - suunto => - 'In Suunto DM5, select your dives and go to File > Export > UDDF.', - scubapro => - 'In Scubapro LogTRAK, select your dives and export as UDDF format.', - ssiMyDiveGuide => - 'In the SSI app, go to My Logbook and export your dives as CSV.', - dan => - 'Export your dives as DAN DL7 (.zxu) files and import them directly ' - 'into Submersion.', - diverLog => - 'In DiverLog+, sync your dives to DiveCloud. Then sign in at ' - 'divecloud.net in a browser, select your dives, and choose Export ' - 'to download a ZIP of DL7 (.zxu) files with photos. Import that ' - 'ZIP directly into Submersion. Desktop DiverLog Full can also ' - 'export .zxu files via Export Dive Data.', - _ => null, - }; -} - -/// A valid (source app, format) combination for the source override dropdown. -/// -/// Each entry represents a specific import pathway that the system supports, -/// pairing an application with the file format it produces. -class SourceOverrideOption { - final SourceApp sourceApp; - final ImportFormat format; - final String displayName; - - const SourceOverrideOption({ - required this.sourceApp, - required this.format, - required this.displayName, - }); - - /// All supported (app, format) combinations for the override dropdown. - static const List supported = [ - SourceOverrideOption( - sourceApp: SourceApp.submersion, - format: ImportFormat.csv, - displayName: 'Submersion (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.submersion, - format: ImportFormat.uddf, - displayName: 'Submersion (UDDF)', - ), - SourceOverrideOption( - sourceApp: SourceApp.subsurface, - format: ImportFormat.csv, - displayName: 'Subsurface (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.subsurface, - format: ImportFormat.subsurfaceXml, - displayName: 'Subsurface (XML)', - ), - SourceOverrideOption( - sourceApp: SourceApp.macdive, - format: ImportFormat.csv, - displayName: 'MacDive (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.macdive, - format: ImportFormat.macdiveXml, - displayName: 'MacDive (XML)', - ), - SourceOverrideOption( - sourceApp: SourceApp.macdive, - format: ImportFormat.macdiveSqlite, - displayName: 'MacDive (SQLite)', - ), - SourceOverrideOption( - sourceApp: SourceApp.divingLog, - format: ImportFormat.csv, - displayName: 'Diving Log (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.diveMate, - format: ImportFormat.csv, - displayName: 'DiveMate (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.shearwater, - format: ImportFormat.csv, - displayName: 'Shearwater (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.shearwater, - format: ImportFormat.shearwaterDb, - displayName: 'Shearwater (Cloud DB)', - ), - SourceOverrideOption( - sourceApp: SourceApp.garminConnect, - format: ImportFormat.csv, - displayName: 'Garmin Connect (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.garminConnect, - format: ImportFormat.fit, - displayName: 'Garmin Connect (FIT)', - ), - SourceOverrideOption( - sourceApp: SourceApp.suunto, - format: ImportFormat.uddf, - displayName: 'Suunto (UDDF)', - ), - SourceOverrideOption( - sourceApp: SourceApp.ssiMyDiveGuide, - format: ImportFormat.csv, - displayName: 'SSI MyDiveGuide (CSV)', - ), - SourceOverrideOption( - sourceApp: SourceApp.scubapro, - format: ImportFormat.uddf, - displayName: 'Scubapro (UDDF)', - ), - SourceOverrideOption( - sourceApp: SourceApp.diverLog, - format: ImportFormat.danDl7, - displayName: 'DiverLog+ (DL7)', - ), - SourceOverrideOption( - sourceApp: SourceApp.dan, - format: ImportFormat.danDl7, - displayName: 'DAN (DL7)', - ), - SourceOverrideOption( - sourceApp: SourceApp.ratio, - format: ImportFormat.ratioXml, - displayName: 'Ratio Computers (XML)', - ), - ]; - - /// Find the matching option for a given app and format pair, or null. - /// - /// When [format] is null (e.g. state from the old SourceApp-only override), - /// returns the first option matching [sourceApp] so the UI still shows a - /// selection. - static SourceOverrideOption? findMatch( - SourceApp? sourceApp, - ImportFormat? format, - ) { - if (sourceApp == null) return null; - for (final option in supported) { - if (option.sourceApp == sourceApp && option.format == format) { - return option; - } - } - // Fallback: match by sourceApp only when format is unknown. - if (format == null) { - for (final option in supported) { - if (option.sourceApp == sourceApp) return option; - } - } - return null; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SourceOverrideOption && - other.sourceApp == sourceApp && - other.format == format; - - @override - int get hashCode => Object.hash(sourceApp, format); -} - -/// Entity types that can be included in an import payload. -/// -/// Mirrors the existing `UddfEntityType` but used across all import formats. -enum ImportEntityType { - dives, - sites, - trips, - equipment, - equipmentSets, - buddies, - diveCenters, - certifications, - courses, - tags, - diveTypes, - serviceRecords, - media; - - String get displayName => switch (this) { - dives => 'Dives', - sites => 'Sites', - trips => 'Trips', - equipment => 'Equipment', - equipmentSets => 'Equipment Sets', - buddies => 'Buddies', - diveCenters => 'Dive Centers', - certifications => 'Certifications', - courses => 'Courses', - tags => 'Tags', - diveTypes => 'Dive Types', - serviceRecords => 'Service Records', - media => 'Photos', - }; - - String get shortName => switch (this) { - dives => 'Dives', - sites => 'Sites', - trips => 'Trips', - equipment => 'Equipment', - equipmentSets => 'Sets', - buddies => 'Buddies', - diveCenters => 'Centers', - certifications => 'Certs', - courses => 'Courses', - tags => 'Tags', - diveTypes => 'Types', - serviceRecords => 'Service', - media => 'Photos', - }; -} +/// File format types that can be detected by the universal import wizard. +enum ImportFormat { + csv, + uddf, + macdiveXml, + macdiveSqlite, + subsurfaceXml, + divingLogXml, + suuntoSml, + suuntoDm5, + fit, + shearwaterDb, + scubapro, + danDl7, + ratioXml, + sqlite, + unknown; + + String get displayName => switch (this) { + csv => 'CSV', + uddf => 'UDDF', + macdiveXml => 'MacDive XML', + macdiveSqlite => 'MacDive SQLite', + subsurfaceXml => 'Subsurface XML', + divingLogXml => 'Diving Log XML', + suuntoSml => 'Suunto SML', + suuntoDm5 => 'Suunto DM5', + fit => 'Garmin FIT', + shearwaterDb => 'Shearwater Cloud', + scubapro => 'Scubapro', + danDl7 => 'DAN DL7', + ratioXml => 'Ratio XML', + sqlite => 'SQLite Database', + unknown => 'Unknown', + }; + + /// Whether this format has a parser implemented in v1.5. + bool get isSupported => switch (this) { + csv || + uddf || + subsurfaceXml || + fit || + shearwaterDb || + macdiveXml || + macdiveSqlite || + danDl7 || + ratioXml => true, + _ => false, + }; +} + +/// Source applications that export dive data. +enum SourceApp { + submersion, + subsurface, + macdive, + divingLog, + diveMate, + shearwater, + suunto, + garminConnect, + scubapro, + ssiMyDiveGuide, + dan, + diverLog, + ratio, + generic; + + String get displayName => switch (this) { + submersion => 'Submersion', + subsurface => 'Subsurface', + macdive => 'MacDive', + divingLog => 'Diving Log', + diveMate => 'DiveMate', + shearwater => 'Shearwater', + suunto => 'Suunto', + garminConnect => 'Garmin Connect', + scubapro => 'Scubapro', + ssiMyDiveGuide => 'SSI MyDiveGuide', + dan => 'DAN', + diverLog => 'DiverLog+', + ratio => 'Ratio Computers', + generic => 'Unknown App', + }; + + /// Instructions for exporting from this app in a supported format. + String? get exportInstructions => switch (this) { + shearwater => null, // Native .db import supported + ratio => null, // Native XML import supported + suunto => + 'In Suunto DM5, select your dives and go to File > Export > UDDF.', + scubapro => + 'In Scubapro LogTRAK, select your dives and export as UDDF format.', + ssiMyDiveGuide => + 'In the SSI app, go to My Logbook and export your dives as CSV.', + dan => + 'Export your dives as DAN DL7 (.zxu) files and import them directly ' + 'into Submersion.', + diverLog => + 'In DiverLog+, sync your dives to DiveCloud. Then sign in at ' + 'divecloud.net in a browser, select your dives, and choose Export ' + 'to download a ZIP of DL7 (.zxu) files with photos. Import that ' + 'ZIP directly into Submersion. Desktop DiverLog Full can also ' + 'export .zxu files via Export Dive Data.', + _ => null, + }; +} + +/// A valid (source app, format) combination for the source override dropdown. +/// +/// Each entry represents a specific import pathway that the system supports, +/// pairing an application with the file format it produces. +class SourceOverrideOption { + final SourceApp sourceApp; + final ImportFormat format; + final String displayName; + + const SourceOverrideOption({ + required this.sourceApp, + required this.format, + required this.displayName, + }); + + /// All supported (app, format) combinations for the override dropdown. + static const List supported = [ + SourceOverrideOption( + sourceApp: SourceApp.submersion, + format: ImportFormat.csv, + displayName: 'Submersion (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.submersion, + format: ImportFormat.uddf, + displayName: 'Submersion (UDDF)', + ), + SourceOverrideOption( + sourceApp: SourceApp.subsurface, + format: ImportFormat.csv, + displayName: 'Subsurface (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.subsurface, + format: ImportFormat.subsurfaceXml, + displayName: 'Subsurface (XML)', + ), + SourceOverrideOption( + sourceApp: SourceApp.macdive, + format: ImportFormat.csv, + displayName: 'MacDive (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.macdive, + format: ImportFormat.macdiveXml, + displayName: 'MacDive (XML)', + ), + SourceOverrideOption( + sourceApp: SourceApp.macdive, + format: ImportFormat.macdiveSqlite, + displayName: 'MacDive (SQLite)', + ), + SourceOverrideOption( + sourceApp: SourceApp.divingLog, + format: ImportFormat.csv, + displayName: 'Diving Log (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.diveMate, + format: ImportFormat.csv, + displayName: 'DiveMate (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.shearwater, + format: ImportFormat.csv, + displayName: 'Shearwater (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + displayName: 'Shearwater (Cloud DB)', + ), + SourceOverrideOption( + sourceApp: SourceApp.garminConnect, + format: ImportFormat.csv, + displayName: 'Garmin Connect (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.garminConnect, + format: ImportFormat.fit, + displayName: 'Garmin Connect (FIT)', + ), + SourceOverrideOption( + sourceApp: SourceApp.suunto, + format: ImportFormat.uddf, + displayName: 'Suunto (UDDF)', + ), + SourceOverrideOption( + sourceApp: SourceApp.ssiMyDiveGuide, + format: ImportFormat.csv, + displayName: 'SSI MyDiveGuide (CSV)', + ), + SourceOverrideOption( + sourceApp: SourceApp.scubapro, + format: ImportFormat.uddf, + displayName: 'Scubapro (UDDF)', + ), + SourceOverrideOption( + sourceApp: SourceApp.diverLog, + format: ImportFormat.danDl7, + displayName: 'DiverLog+ (DL7)', + ), + SourceOverrideOption( + sourceApp: SourceApp.dan, + format: ImportFormat.danDl7, + displayName: 'DAN (DL7)', + ), + SourceOverrideOption( + sourceApp: SourceApp.ratio, + format: ImportFormat.ratioXml, + displayName: 'Ratio Computers (XML)', + ), + ]; + + /// Find the matching option for a given app and format pair, or null. + /// + /// When [format] is null (e.g. state from the old SourceApp-only override), + /// returns the first option matching [sourceApp] so the UI still shows a + /// selection. + static SourceOverrideOption? findMatch( + SourceApp? sourceApp, + ImportFormat? format, + ) { + if (sourceApp == null) return null; + for (final option in supported) { + if (option.sourceApp == sourceApp && option.format == format) { + return option; + } + } + // Fallback: match by sourceApp only when format is unknown. + if (format == null) { + for (final option in supported) { + if (option.sourceApp == sourceApp) return option; + } + } + return null; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SourceOverrideOption && + other.sourceApp == sourceApp && + other.format == format; + + @override + int get hashCode => Object.hash(sourceApp, format); +} + +/// Entity types that can be included in an import payload. +/// +/// Mirrors the existing `UddfEntityType` but used across all import formats. +enum ImportEntityType { + dives, + sites, + trips, + equipment, + equipmentSets, + buddies, + diveCenters, + certifications, + courses, + tags, + diveTypes, + serviceRecords, + media; + + String get displayName => switch (this) { + dives => 'Dives', + sites => 'Sites', + trips => 'Trips', + equipment => 'Equipment', + equipmentSets => 'Equipment Sets', + buddies => 'Buddies', + diveCenters => 'Dive Centers', + certifications => 'Certifications', + courses => 'Courses', + tags => 'Tags', + diveTypes => 'Dive Types', + serviceRecords => 'Service Records', + media => 'Photos', + }; + + String get shortName => switch (this) { + dives => 'Dives', + sites => 'Sites', + trips => 'Trips', + equipment => 'Equipment', + equipmentSets => 'Sets', + buddies => 'Buddies', + diveCenters => 'Centers', + certifications => 'Certs', + courses => 'Courses', + tags => 'Tags', + diveTypes => 'Types', + serviceRecords => 'Service', + media => 'Photos', + }; +} diff --git a/lib/features/universal_import/presentation/widgets/import_summary_step.dart b/lib/features/universal_import/presentation/widgets/import_summary_step.dart index 0830eb1d1b..38aff542bb 100644 --- a/lib/features/universal_import/presentation/widgets/import_summary_step.dart +++ b/lib/features/universal_import/presentation/widgets/import_summary_step.dart @@ -1,177 +1,177 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; -import 'package:submersion/features/universal_import/data/models/import_enums.dart'; -import 'package:submersion/features/universal_import/data/models/import_warning.dart'; -import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; - -/// Step 5: Import summary with counts per entity type. -class ImportSummaryStep extends ConsumerWidget { - const ImportSummaryStep({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(universalImportNotifierProvider); - final theme = Theme.of(context); - final warnings = state.payload?.warnings ?? const []; - - return Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ExcludeSemantics( - child: Icon( - Icons.check_circle, - size: 80, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(height: 24), - Text( - context.l10n.universalImport_label_importComplete, - style: theme.textTheme.headlineMedium, - ), - const SizedBox(height: 16), - for (final entry in state.importCounts.entries) - _SummaryRow( - label: entry.key.displayName, - value: entry.value.toString(), - icon: _iconFor(entry.key), - color: theme.colorScheme.primary, - ), - if (warnings.isNotEmpty) ...[ - const SizedBox(height: 24), - _WarningsSection(warnings: warnings), - ], - const SizedBox(height: 32), - FilledButton( - onPressed: () { - ref.read(universalImportNotifierProvider.notifier).reset(); - context.pop(); - }, - child: Text(context.l10n.universalImport_action_done), - ), - ], - ), - ); - } - - static IconData _iconFor(ImportEntityType type) { - return switch (type) { - ImportEntityType.dives => Icons.scuba_diving, - ImportEntityType.sites => Icons.location_on_outlined, - ImportEntityType.trips => Icons.card_travel, - ImportEntityType.equipment => Icons.build_outlined, - ImportEntityType.equipmentSets => Icons.inventory_2_outlined, - ImportEntityType.buddies => Icons.person_outline, - ImportEntityType.diveCenters => Icons.store_outlined, - ImportEntityType.certifications => Icons.workspace_premium_outlined, - ImportEntityType.courses => Icons.school_outlined, - ImportEntityType.tags => Icons.label_outline, - ImportEntityType.diveTypes => Icons.category_outlined, - ImportEntityType.serviceRecords => Icons.handyman_outlined, - ImportEntityType.media => Icons.photo_library_outlined, - }; - } -} - -class _WarningsSection extends StatelessWidget { - const _WarningsSection({required this.warnings}); - - final List warnings; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Card( - color: theme.colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final w in warnings) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - _iconFor(w.severity), - color: _colorFor(w.severity, theme), - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text(w.message, style: theme.textTheme.bodyMedium), - ), - ], - ), - if (w != warnings.last) const SizedBox(height: 12), - ], - ], - ), - ), - ), - ); - } - - static IconData _iconFor(ImportWarningSeverity s) => switch (s) { - ImportWarningSeverity.info => Icons.info_outline, - ImportWarningSeverity.warning => Icons.warning_amber_rounded, - ImportWarningSeverity.error => Icons.error_outline, - }; - - static Color _colorFor(ImportWarningSeverity s, ThemeData theme) => - switch (s) { - ImportWarningSeverity.info => theme.colorScheme.primary, - ImportWarningSeverity.warning => theme.colorScheme.tertiary, - ImportWarningSeverity.error => theme.colorScheme.error, - }; -} - -class _SummaryRow extends StatelessWidget { - const _SummaryRow({ - required this.label, - required this.value, - required this.icon, - required this.color, - }); - - final String label; - final String value; - final IconData icon; - final Color color; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(width: 8), - Text( - label, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - Text( - value, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; + +/// Step 5: Import summary with counts per entity type. +class ImportSummaryStep extends ConsumerWidget { + const ImportSummaryStep({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(universalImportNotifierProvider); + final theme = Theme.of(context); + final warnings = state.payload?.warnings ?? const []; + + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ExcludeSemantics( + child: Icon( + Icons.check_circle, + size: 80, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 24), + Text( + context.l10n.universalImport_label_importComplete, + style: theme.textTheme.headlineMedium, + ), + const SizedBox(height: 16), + for (final entry in state.importCounts.entries) + _SummaryRow( + label: entry.key.displayName, + value: entry.value.toString(), + icon: _iconFor(entry.key), + color: theme.colorScheme.primary, + ), + if (warnings.isNotEmpty) ...[ + const SizedBox(height: 24), + _WarningsSection(warnings: warnings), + ], + const SizedBox(height: 32), + FilledButton( + onPressed: () { + ref.read(universalImportNotifierProvider.notifier).reset(); + context.pop(); + }, + child: Text(context.l10n.universalImport_action_done), + ), + ], + ), + ); + } + + static IconData _iconFor(ImportEntityType type) { + return switch (type) { + ImportEntityType.dives => Icons.scuba_diving, + ImportEntityType.sites => Icons.location_on_outlined, + ImportEntityType.trips => Icons.card_travel, + ImportEntityType.equipment => Icons.build_outlined, + ImportEntityType.equipmentSets => Icons.inventory_2_outlined, + ImportEntityType.buddies => Icons.person_outline, + ImportEntityType.diveCenters => Icons.store_outlined, + ImportEntityType.certifications => Icons.workspace_premium_outlined, + ImportEntityType.courses => Icons.school_outlined, + ImportEntityType.tags => Icons.label_outline, + ImportEntityType.diveTypes => Icons.category_outlined, + ImportEntityType.serviceRecords => Icons.handyman_outlined, + ImportEntityType.media => Icons.photo_library_outlined, + }; + } +} + +class _WarningsSection extends StatelessWidget { + const _WarningsSection({required this.warnings}); + + final List warnings; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Card( + color: theme.colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final w in warnings) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _iconFor(w.severity), + color: _colorFor(w.severity, theme), + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text(w.message, style: theme.textTheme.bodyMedium), + ), + ], + ), + if (w != warnings.last) const SizedBox(height: 12), + ], + ], + ), + ), + ), + ); + } + + static IconData _iconFor(ImportWarningSeverity s) => switch (s) { + ImportWarningSeverity.info => Icons.info_outline, + ImportWarningSeverity.warning => Icons.warning_amber_rounded, + ImportWarningSeverity.error => Icons.error_outline, + }; + + static Color _colorFor(ImportWarningSeverity s, ThemeData theme) => + switch (s) { + ImportWarningSeverity.info => theme.colorScheme.primary, + ImportWarningSeverity.warning => theme.colorScheme.tertiary, + ImportWarningSeverity.error => theme.colorScheme.error, + }; +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} From 5b0d19d9448a9f2f3fa0002aba3ce8f22fdd787b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:08:52 -0400 Subject: [PATCH 085/122] fix(sites): stop wiping site fields on altitude and GPS write-backs (#1187) dive.site was hydrated with 9 of 24 columns; the altitude resolver copied that partial entity and updateSite rewrote every column, nulling difficulty, water type, city, island, body of water, hazards, entry/exit method and the shared flag on any import or edit of a dive at a site with no stored altitude. The row then synced as a newer edit. Hydrate sites through one shared mapper and patch only the altitude/coordinate columns. --- .../repositories/dive_repository_impl.dart | 32 +--- .../services/dive_altitude_enricher.dart | 7 +- .../presentation/pages/dive_edit_page.dart | 29 +++- .../data/mappers/dive_site_row_mapper.dart | 49 ++++++ .../repositories/site_repository_impl.dart | 63 +++---- .../providers/site_providers.dart | 23 +++ .../domain/services/altitude_resolver.dart | 15 +- .../dive_repository_site_mapping_test.dart | 92 ++++++++++ ...ve_altitude_enricher_site_fields_test.dart | 158 ++++++++++++++++++ .../site_list_notifier_patch_test.dart | 122 ++++++++++++++ .../services/altitude_resolver_test.dart | 15 +- 11 files changed, 515 insertions(+), 90 deletions(-) create mode 100644 lib/features/dive_sites/data/mappers/dive_site_row_mapper.dart create mode 100644 test/features/dive_log/data/repositories/dive_repository_site_mapping_test.dart create mode 100644 test/features/dive_log/domain/services/dive_altitude_enricher_site_fields_test.dart create mode 100644 test/features/dive_sites/presentation/providers/site_list_notifier_patch_test.dart diff --git a/lib/features/dive_log/data/repositories/dive_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_repository_impl.dart index 1b77552718..c44d76aa01 100644 --- a/lib/features/dive_log/data/repositories/dive_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_repository_impl.dart @@ -21,6 +21,7 @@ import 'package:submersion/features/dive_log/domain/entities/dive_times.dart' import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart' as domain; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart'; +import 'package:submersion/features/dive_sites/data/mappers/dive_site_row_mapper.dart'; import 'package:submersion/features/dive_log/domain/entities/source_profile.dart' as domain; import 'package:submersion/features/dive_log/domain/entities/profile_event.dart'; @@ -2995,22 +2996,7 @@ class DiveRepository { List buddies = const [], }) { // Map site if exists - domain.DiveSite? domainSite; - if (site != null) { - domainSite = domain.DiveSite( - id: site.id, - name: site.name, - description: site.description, - location: site.latitude != null && site.longitude != null - ? domain.GeoPoint(site.latitude!, site.longitude!) - : null, - maxDepth: site.maxDepth, - country: site.country, - region: site.region, - rating: site.rating, - notes: site.notes, - ); - } + final domainSite = site == null ? null : mapDiveSiteRow(site); // Map dive center if exists domain.DiveCenter? domainCenter; @@ -3356,19 +3342,7 @@ class DiveRepository { ..where((t) => t.id.equals(row.siteId!)); final siteRow = await siteQuery.getSingleOrNull(); if (siteRow != null) { - site = domain.DiveSite( - id: siteRow.id, - name: siteRow.name, - description: siteRow.description, - location: siteRow.latitude != null && siteRow.longitude != null - ? domain.GeoPoint(siteRow.latitude!, siteRow.longitude!) - : null, - maxDepth: siteRow.maxDepth, - country: siteRow.country, - region: siteRow.region, - rating: siteRow.rating, - notes: siteRow.notes, - ); + site = mapDiveSiteRow(siteRow); } } diff --git a/lib/features/dive_log/domain/services/dive_altitude_enricher.dart b/lib/features/dive_log/domain/services/dive_altitude_enricher.dart index 0e9cbc33f3..0cc3936b46 100644 --- a/lib/features/dive_log/domain/services/dive_altitude_enricher.dart +++ b/lib/features/dive_log/domain/services/dive_altitude_enricher.dart @@ -38,9 +38,12 @@ class DiveAltitudeEnricher { exitLocation: dive.exitLocation, site: dive.site, ); - final writeBack = resolution.siteWriteBack; + final writeBack = resolution.siteAltitudeWriteBack; if (writeBack != null) { - await _sites.updateSite(writeBack); + await _sites.updateSiteAltitude( + writeBack.siteId, + writeBack.altitudeMeters, + ); } final meters = resolution.altitudeMeters; if (meters == null) return false; diff --git a/lib/features/dive_log/presentation/pages/dive_edit_page.dart b/lib/features/dive_log/presentation/pages/dive_edit_page.dart index 2e5cef8f5c..da23221d9d 100644 --- a/lib/features/dive_log/presentation/pages/dive_edit_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_edit_page.dart @@ -2248,19 +2248,26 @@ class _DiveEditPageState extends ConsumerState { var updatedSite = _selectedSite!.copyWith(location: gps); // A site gaining coordinates should also gain its altitude, so later dives // there resolve locally without a lookup. + double? lookedUpAltitude; if (updatedSite.altitude == null) { - final meters = await ref + lookedUpAltitude = await ref .read(elevationServiceProvider) .fetchElevation(latitude: gps.latitude, longitude: gps.longitude); if (!mounted) return; - if (meters != null) { - updatedSite = updatedSite.copyWith(altitude: meters); + if (lookedUpAltitude != null) { + updatedSite = updatedSite.copyWith(altitude: lookedUpAltitude); } } - // Update the site via the notifier + // Patch only the coordinate columns: _selectedSite may be a partially + // hydrated entity, and a whole-entity update would wipe the rest + // (issue #1187). final siteNotifier = ref.read(siteListNotifierProvider.notifier); - await siteNotifier.updateSite(updatedSite); + await siteNotifier.updateSiteCoordinates( + updatedSite.id, + gps, + altitude: lookedUpAltitude, + ); setState(() { _selectedSite = updatedSite; @@ -4102,12 +4109,16 @@ class _DiveEditPageState extends ConsumerState { ); if (!mounted) return; - final writeBack = resolution.siteWriteBack; + final writeBack = resolution.siteAltitudeWriteBack; if (writeBack != null) { - await ref.read(siteListNotifierProvider.notifier).updateSite(writeBack); + await ref + .read(siteListNotifierProvider.notifier) + .updateSiteAltitude(writeBack.siteId, writeBack.altitudeMeters); if (!mounted) return; - if (_selectedSite?.id == writeBack.id) { - _selectedSite = writeBack; + if (_selectedSite?.id == writeBack.siteId) { + _selectedSite = _selectedSite?.copyWith( + altitude: writeBack.altitudeMeters, + ); } } diff --git a/lib/features/dive_sites/data/mappers/dive_site_row_mapper.dart b/lib/features/dive_sites/data/mappers/dive_site_row_mapper.dart new file mode 100644 index 0000000000..be14c4d71e --- /dev/null +++ b/lib/features/dive_sites/data/mappers/dive_site_row_mapper.dart @@ -0,0 +1,49 @@ +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart' + as domain; + +/// Maps a `dive_sites` row to the domain entity, every column included. +/// +/// This is the single row-to-entity mapping for sites. Anything that +/// hydrates a site from a row (the site repository, the dive repository's +/// `dive.site`) must use it: a partial entity that later flows through +/// `updateSite` rewrites every column and wipes the ones it never carried +/// (issue #1187). `photoIds` is not a column and is left empty here; the +/// site repository fills it where it is needed. +domain.DiveSite mapDiveSiteRow(DiveSite row) { + return domain.DiveSite( + id: row.id, + diverId: row.diverId, + name: row.name, + description: row.description, + location: row.latitude != null && row.longitude != null + ? domain.GeoPoint(row.latitude!, row.longitude!) + : null, + minDepth: row.minDepth, + maxDepth: row.maxDepth, + difficulty: domain.SiteDifficulty.fromString(row.difficulty), + waterType: row.waterType == null + ? null + : WaterType.values.asNameMap()[row.waterType], + country: row.country, + region: row.region, + city: row.city, + island: row.island, + bodyOfWater: row.bodyOfWater, + rating: row.rating, + notes: row.notes, + hazards: row.hazards, + accessNotes: row.accessNotes, + mooringNumber: row.mooringNumber, + parkingInfo: row.parkingInfo, + altitude: row.altitude, + entryMethod: row.entryMethod == null + ? null + : EntryMethod.values.asNameMap()[row.entryMethod], + exitMethod: row.exitMethod == null + ? null + : EntryMethod.values.asNameMap()[row.exitMethod], + isShared: row.isShared, + ); +} diff --git a/lib/features/dive_sites/data/repositories/site_repository_impl.dart b/lib/features/dive_sites/data/repositories/site_repository_impl.dart index 834b235e00..8fa32d621b 100644 --- a/lib/features/dive_sites/data/repositories/site_repository_impl.dart +++ b/lib/features/dive_sites/data/repositories/site_repository_impl.dart @@ -1,7 +1,6 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; -import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/data/visibility/visibility_filter.dart'; import 'package:submersion/core/database/database.dart'; @@ -9,6 +8,7 @@ import 'package:submersion/core/performance/perf_timer.dart'; import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/dive_sites/data/mappers/dive_site_row_mapper.dart'; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart' as domain; import 'package:submersion/features/media/data/repositories/media_repository.dart'; @@ -233,6 +233,30 @@ class SiteRepository { /// /// Used by the UDDF importer to persist columns that do not flow through /// the [domain.DiveSite] entity (e.g. MacDive waterType). + /// Stores a looked-up altitude for [siteId] without touching any other + /// column. Use this, never `updateSite` with a copied entity, for the + /// altitude write-back (issue #1187). + Future updateSiteAltitude(String siteId, double altitudeMeters) => + applyImportedMetadata( + siteId, + DiveSitesCompanion(altitude: Value(altitudeMeters)), + ); + + /// Stores coordinates (and optionally an altitude) for [siteId] without + /// touching any other column. + Future updateSiteCoordinates( + String siteId, + domain.GeoPoint location, { + double? altitude, + }) => applyImportedMetadata( + siteId, + DiveSitesCompanion( + latitude: Value(location.latitude), + longitude: Value(location.longitude), + altitude: altitude == null ? const Value.absent() : Value(altitude), + ), + ); + /// Only columns set on [patch] are written; others are left untouched. /// Marks the row pending for sync. Future applyImportedMetadata( @@ -817,42 +841,7 @@ class SiteRepository { } } - domain.DiveSite _mapRowToSite(DiveSite row) { - return domain.DiveSite( - id: row.id, - diverId: row.diverId, - name: row.name, - description: row.description, - location: row.latitude != null && row.longitude != null - ? domain.GeoPoint(row.latitude!, row.longitude!) - : null, - minDepth: row.minDepth, - maxDepth: row.maxDepth, - difficulty: domain.SiteDifficulty.fromString(row.difficulty), - waterType: row.waterType == null - ? null - : WaterType.values.asNameMap()[row.waterType], - country: row.country, - region: row.region, - city: row.city, - island: row.island, - bodyOfWater: row.bodyOfWater, - rating: row.rating, - notes: row.notes, - hazards: row.hazards, - accessNotes: row.accessNotes, - mooringNumber: row.mooringNumber, - parkingInfo: row.parkingInfo, - altitude: row.altitude, - entryMethod: row.entryMethod == null - ? null - : EntryMethod.values.asNameMap()[row.entryMethod], - exitMethod: row.exitMethod == null - ? null - : EntryMethod.values.asNameMap()[row.exitMethod], - isShared: row.isShared, - ); - } + domain.DiveSite _mapRowToSite(DiveSite row) => mapDiveSiteRow(row); Future _updateSiteRow(domain.DiveSite site, int now) async { await (_db.update(_db.diveSites)..where((t) => t.id.equals(site.id))).write( diff --git a/lib/features/dive_sites/presentation/providers/site_providers.dart b/lib/features/dive_sites/presentation/providers/site_providers.dart index 4cabf375b1..ae29422524 100644 --- a/lib/features/dive_sites/presentation/providers/site_providers.dart +++ b/lib/features/dive_sites/presentation/providers/site_providers.dart @@ -415,6 +415,29 @@ class SiteListNotifier await _loadSites(); } + /// Altitude write-back for a looked-up site altitude. Patches the one + /// column; never send a copied entity through [updateSite] for this + /// (issue #1187). + Future updateSiteAltitude(String siteId, double altitudeMeters) async { + await _repository.updateSiteAltitude(siteId, altitudeMeters); + await _loadSites(); + } + + /// Coordinates (and optionally altitude) write-back, e.g. from a photo's + /// GPS. Patches only those columns. + Future updateSiteCoordinates( + String siteId, + domain.GeoPoint location, { + double? altitude, + }) async { + await _repository.updateSiteCoordinates( + siteId, + location, + altitude: altitude, + ); + await _loadSites(); + } + Future deleteSite(String id) async { await _repository.deleteSite(id); await _loadSites(); diff --git a/lib/features/weather/domain/services/altitude_resolver.dart b/lib/features/weather/domain/services/altitude_resolver.dart index 20f5e82b08..c58103ba79 100644 --- a/lib/features/weather/domain/services/altitude_resolver.dart +++ b/lib/features/weather/domain/services/altitude_resolver.dart @@ -3,14 +3,17 @@ import 'package:submersion/features/weather/data/services/elevation_service.dart /// Result of an altitude resolution. /// -/// [siteWriteBack] is non-null only when the altitude came from a lookup of -/// the site's own coordinates: the caller should persist it so future dives -/// at that site resolve locally without a network call. +/// [siteAltitudeWriteBack] is non-null only when the altitude came from a +/// lookup of the site's own coordinates: the caller should persist it so +/// future dives at that site resolve locally without a network call. It is +/// deliberately an id plus a number, not a site entity: callers must patch +/// the altitude column alone, because writing a whole entity that was +/// hydrated partially wipes every column it did not carry (issue #1187). class AltitudeResolution { - const AltitudeResolution({this.altitudeMeters, this.siteWriteBack}); + const AltitudeResolution({this.altitudeMeters, this.siteAltitudeWriteBack}); final double? altitudeMeters; - final DiveSite? siteWriteBack; + final ({String siteId, double altitudeMeters})? siteAltitudeWriteBack; } /// Encodes the altitude precedence rule from the 2026-08-06 conditions spec: @@ -52,7 +55,7 @@ class AltitudeResolver { if (meters != null) { return AltitudeResolution( altitudeMeters: meters, - siteWriteBack: site.copyWith(altitude: meters), + siteAltitudeWriteBack: (siteId: site.id, altitudeMeters: meters), ); } } diff --git a/test/features/dive_log/data/repositories/dive_repository_site_mapping_test.dart b/test/features/dive_log/data/repositories/dive_repository_site_mapping_test.dart new file mode 100644 index 0000000000..00c23bdc68 --- /dev/null +++ b/test/features/dive_log/data/repositories/dive_repository_site_mapping_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1187: `dive.site` used to be hydrated with 9 of the entity's 24 +/// fields. Any caller that copied that entity and saved it wiped the rest. +void main() { + late DiveRepository dives; + late SiteRepository sites; + + setUp(() async { + await setUpTestDatabase(); + dives = DiveRepository(); + sites = SiteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + const richSite = DiveSite( + id: 'site-rich', + name: 'Hertenstein', + description: 'Steep wall off the peninsula', + location: GeoPoint(47.027631, 8.400640), + minDepth: 5, + maxDepth: 40, + difficulty: SiteDifficulty.advanced, + waterType: WaterType.fresh, + country: 'Switzerland', + region: 'Lucerne', + city: 'Weggis', + island: 'None', + bodyOfWater: 'Lake Lucerne', + rating: 4, + notes: 'Bring a torch', + hazards: 'Boat traffic', + accessNotes: 'Steps down from the road', + mooringNumber: 'M7', + parkingInfo: 'Lay-by 100 m north', + altitude: 434, + entryMethod: EntryMethod.shore, + exitMethod: EntryMethod.ladder, + isShared: true, + ); + + Dive diveAt(DiveSite site) => Dive( + id: 'd1', + diveNumber: 1, + dateTime: DateTime(2026, 8, 25, 10, 0), + site: site, + tanks: const [], + profile: const [], + equipment: const [], + notes: '', + photoIds: const [], + sightings: const [], + weights: const [], + tags: const [], + ); + + void expectFullSite(DiveSite? site) { + expect(site, isNotNull); + // Photo ids are loaded separately by the site repository; everything + // else on the row must be present on the dive's site. + expect(site!.copyWith(photoIds: const []), richSite); + } + + test('getDiveById hydrates every column of the linked site', () async { + await sites.createSite(richSite); + await dives.createDive(diveAt(richSite)); + + final stored = await dives.getDiveById('d1'); + + expectFullSite(stored!.site); + }); + + test('getAllDives hydrates every column of the linked site', () async { + await sites.createSite(richSite); + await dives.createDive(diveAt(richSite)); + + final all = await dives.getAllDives(); + + expect(all, hasLength(1)); + expectFullSite(all.single.site); + }); +} diff --git a/test/features/dive_log/domain/services/dive_altitude_enricher_site_fields_test.dart b/test/features/dive_log/domain/services/dive_altitude_enricher_site_fields_test.dart new file mode 100644 index 0000000000..df0abf1e92 --- /dev/null +++ b/test/features/dive_log/domain/services/dive_altitude_enricher_site_fields_test.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart' show AppDatabase; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/services/dive_altitude_enricher.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/weather/data/services/elevation_service.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Regression for issue #1187: the site altitude write-back used to push a +/// partial `dive.site` entity through `updateSite`, which writes every +/// column, so difficulty, water type, city, island, body of water, hazards +/// and the shared flag were nulled on every import at a site with no stored +/// altitude. The wipe then synced to the diver's other devices. +void main() { + late AppDatabase db; + late DiveRepository dives; + late SiteRepository sites; + + setUp(() async { + db = await setUpTestDatabase(); + dives = DiveRepository(); + sites = SiteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + ElevationService fixedElevation() => ElevationService( + client: MockClient( + (_) async => http.Response( + jsonEncode({ + 'elevation': [740.2], + }), + 200, + ), + ), + ); + + Future createRichSite() => sites.createSite( + const DiveSite( + id: 'site-rich', + name: 'Hertenstein', + description: 'Steep wall off the peninsula', + location: GeoPoint(47.027631, 8.400640), + minDepth: 5, + maxDepth: 40, + difficulty: SiteDifficulty.advanced, + waterType: WaterType.fresh, + country: 'Switzerland', + region: 'Lucerne', + city: 'Weggis', + island: 'None', + bodyOfWater: 'Lake Lucerne', + rating: 4, + notes: 'Bring a torch', + hazards: 'Boat traffic', + accessNotes: 'Steps down from the road', + mooringNumber: 'M7', + parkingInfo: 'Lay-by 100 m north', + entryMethod: EntryMethod.shore, + exitMethod: EntryMethod.ladder, + isShared: true, + ), + ); + + Dive diveAt(DiveSite site) => Dive( + id: 'd1', + diveNumber: 1, + dateTime: DateTime(2026, 8, 25, 10, 0), + site: site, + tanks: const [], + profile: const [], + equipment: const [], + notes: '', + photoIds: const [], + sightings: const [], + weights: const [], + tags: const [], + ); + + test('site altitude write-back keeps every other site field intact even ' + 'when the dive carries a partial site entity', () async { + await createRichSite(); + // Mimic the sparse site the dive repository used to hydrate: id, name + // and coordinates only. Everything else is null on this entity. + const partialSite = DiveSite( + id: 'site-rich', + name: 'Hertenstein', + location: GeoPoint(47.027631, 8.400640), + ); + final dive = await dives.createDive(diveAt(partialSite)); + final enricher = DiveAltitudeEnricher( + elevationService: fixedElevation(), + diveRepository: dives, + siteRepository: sites, + ); + + final applied = await enricher.applyForImportedDive(dive); + + expect(applied, isTrue); + final stored = await sites.getSiteById('site-rich'); + expect(stored, isNotNull); + expect(stored!.altitude, 740.0, reason: 'the write-back must land'); + expect(stored.difficulty, SiteDifficulty.advanced); + expect(stored.waterType, WaterType.fresh); + expect(stored.minDepth, 5); + expect(stored.maxDepth, 40); + expect(stored.country, 'Switzerland'); + expect(stored.region, 'Lucerne'); + expect(stored.city, 'Weggis'); + expect(stored.island, 'None'); + expect(stored.bodyOfWater, 'Lake Lucerne'); + expect(stored.rating, 4); + expect(stored.notes, 'Bring a torch'); + expect(stored.hazards, 'Boat traffic'); + expect(stored.accessNotes, 'Steps down from the road'); + expect(stored.mooringNumber, 'M7'); + expect(stored.parkingInfo, 'Lay-by 100 m north'); + expect(stored.entryMethod, EntryMethod.shore); + expect(stored.exitMethod, EntryMethod.ladder); + expect(stored.isShared, isTrue); + expect(stored.description, 'Steep wall off the peninsula'); + }); + + test('site altitude write-back marks the site pending for sync', () async { + await createRichSite(); + final dive = await dives.createDive( + diveAt( + const DiveSite( + id: 'site-rich', + name: 'Hertenstein', + location: GeoPoint(47.027631, 8.400640), + ), + ), + ); + final enricher = DiveAltitudeEnricher( + elevationService: fixedElevation(), + diveRepository: dives, + siteRepository: sites, + ); + + await enricher.applyForImportedDive(dive); + + final pending = await (db.select( + db.syncRecords, + )..where((t) => t.recordId.equals('site-rich'))).get(); + expect(pending, isNotEmpty, reason: 'the altitude change must sync'); + }); +} diff --git a/test/features/dive_sites/presentation/providers/site_list_notifier_patch_test.dart b/test/features/dive_sites/presentation/providers/site_list_notifier_patch_test.dart new file mode 100644 index 0000000000..71a2f9ba0a --- /dev/null +++ b/test/features/dive_sites/presentation/providers/site_list_notifier_patch_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/dive_sites/presentation/providers/site_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1187: the dive edit page's altitude and photo-GPS write-backs used +/// to send a (possibly partial) site entity through `updateSite`, which +/// rewrites every column. These targeted patches touch only the columns +/// they are named for. +void main() { + late ProviderContainer container; + late SiteRepository siteRepository; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + siteRepository = SiteRepository(); + container = ProviderContainer( + overrides: [ + siteRepositoryProvider.overrideWithValue(siteRepository), + sharedPreferencesProvider.overrideWithValue(prefs), + validatedCurrentDiverIdProvider.overrideWith((ref) async => null), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await tearDownTestDatabase(); + }); + + const richSite = DiveSite( + id: 'site-rich', + name: 'Hertenstein', + location: GeoPoint(47.027631, 8.400640), + minDepth: 5, + maxDepth: 40, + difficulty: SiteDifficulty.advanced, + waterType: WaterType.fresh, + country: 'Switzerland', + region: 'Lucerne', + city: 'Weggis', + bodyOfWater: 'Lake Lucerne', + rating: 4, + hazards: 'Boat traffic', + entryMethod: EntryMethod.shore, + isShared: true, + ); + + test('updateSiteAltitude writes only the altitude', () async { + await siteRepository.createSite(richSite); + final notifier = container.read(siteListNotifierProvider.notifier); + + await notifier.updateSiteAltitude('site-rich', 434.0); + + final stored = await siteRepository.getSiteById('site-rich'); + expect(stored!.altitude, 434.0); + expect(stored.difficulty, SiteDifficulty.advanced); + expect(stored.waterType, WaterType.fresh); + expect(stored.city, 'Weggis'); + expect(stored.bodyOfWater, 'Lake Lucerne'); + expect(stored.hazards, 'Boat traffic'); + expect(stored.entryMethod, EntryMethod.shore); + expect(stored.isShared, isTrue); + expect(stored.location, const GeoPoint(47.027631, 8.400640)); + }); + + test('updateSiteCoordinates writes location and altitude only', () async { + await siteRepository.createSite(richSite); + final notifier = container.read(siteListNotifierProvider.notifier); + + await notifier.updateSiteCoordinates( + 'site-rich', + const GeoPoint(12.1609, -68.2836), + altitude: 3.0, + ); + + final stored = await siteRepository.getSiteById('site-rich'); + expect(stored!.location, const GeoPoint(12.1609, -68.2836)); + expect(stored.altitude, 3.0); + expect(stored.difficulty, SiteDifficulty.advanced); + expect(stored.rating, 4); + expect(stored.bodyOfWater, 'Lake Lucerne'); + expect(stored.isShared, isTrue); + }); + + test( + 'updateSiteCoordinates without altitude leaves altitude alone', + () async { + await siteRepository.createSite(richSite.copyWith(altitude: 434.0)); + final notifier = container.read(siteListNotifierProvider.notifier); + + await notifier.updateSiteCoordinates( + 'site-rich', + const GeoPoint(12.1609, -68.2836), + ); + + final stored = await siteRepository.getSiteById('site-rich'); + expect(stored!.altitude, 434.0); + }, + ); + + test('targeted patches refresh the notifier state', () async { + await siteRepository.createSite(richSite); + final notifier = container.read(siteListNotifierProvider.notifier); + + await notifier.updateSiteAltitude('site-rich', 434.0); + + final state = container.read(siteListNotifierProvider); + final site = state.value!.singleWhere((s) => s.id == 'site-rich'); + expect(site.altitude, 434.0); + }); +} diff --git a/test/features/weather/domain/services/altitude_resolver_test.dart b/test/features/weather/domain/services/altitude_resolver_test.dart index b728992f03..bb26420d7d 100644 --- a/test/features/weather/domain/services/altitude_resolver_test.dart +++ b/test/features/weather/domain/services/altitude_resolver_test.dart @@ -48,7 +48,7 @@ void main() { ); expect(result.altitudeMeters, 740.0); - expect(result.siteWriteBack, isNull); + expect(result.siteAltitudeWriteBack, isNull); expect(requests.single.queryParameters['latitude'], '46.4'); }); @@ -71,7 +71,7 @@ void main() { ); expect(result.altitudeMeters, 300.0); - expect(result.siteWriteBack, isNull); + expect(result.siteAltitudeWriteBack, isNull); }); test('uses site altitude when the dive has no GPS', () async { @@ -94,9 +94,10 @@ void main() { ); expect(result.altitudeMeters, 740.0); - expect(result.siteWriteBack, isNotNull); - expect(result.siteWriteBack!.altitude, 740.0); - expect(result.siteWriteBack!.id, 'site-1'); + expect(result.siteAltitudeWriteBack, ( + siteId: 'site-1', + altitudeMeters: 740.0, + )); }); test('returns empty resolution when nothing is available', () async { @@ -108,7 +109,7 @@ void main() { final result = await resolver.resolve(); expect(result.altitudeMeters, isNull); - expect(result.siteWriteBack, isNull); + expect(result.siteAltitudeWriteBack, isNull); expect(requests, isEmpty); }); @@ -121,7 +122,7 @@ void main() { ); expect(result.altitudeMeters, isNull); - expect(result.siteWriteBack, isNull); + expect(result.siteAltitudeWriteBack, isNull); }); test('cache dedupes lookups for nearby coordinates', () async { From e1b1997f98d02fdac7dbf6baa17c1223811da930 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:09:52 -0400 Subject: [PATCH 086/122] fix(import): honour the review selection for photos Media appears in review like any other entity, so it gets checkboxes. Nothing read them at commit, meaning a deselected photo was imported anyway. attachResolvedPhotos now filters on the selection. Refs #1147 --- .../data/adapters/universal_adapter.dart | 10 ++++ .../universal_adapter_photo_test.dart | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index 2f861e2dab..031ce66dbb 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -651,6 +651,7 @@ class UniversalAdapter implements ImportSourceAdapter { diveIdByIndex: result.diveIdByIndex, removedDiveIds: removedDiveIds, dives: payload.entitiesOf(ui.ImportEntityType.dives), + selectedIndices: selections[wizard.ImportEntityType.media], attach: (file, diveId, takenAt, latitude, longitude) async { await _ref .read(mediaImportServiceProvider) @@ -1019,6 +1020,9 @@ class UniversalAdapter implements ImportSourceAdapter { /// multi-dive logbook attaches each photo to exactly the dive that /// referenced it. /// + /// [selectedIndices] is the review step's selection for the media group; + /// null means every resolved photo is attached. + /// /// A copy failure is counted and skipped rather than thrown: the dive /// import has already succeeded and must not be undone by a photo. Unlike /// [attachImportedPhotos] the failure is not silent, because the caller @@ -1031,6 +1035,7 @@ class UniversalAdapter implements ImportSourceAdapter { required Map diveIdByIndex, required Set removedDiveIds, required List> dives, + Set? selectedIndices, required Future Function( File file, String diveId, @@ -1045,6 +1050,11 @@ class UniversalAdapter implements ImportSourceAdapter { for (final entry in resolvedPathByIndex.entries) { final mediaIndex = entry.key; if (mediaIndex < 0 || mediaIndex >= media.length) continue; + // Photos appear in review like any other entity, so a deselected one + // must actually be left out rather than quietly imported anyway. + if (selectedIndices != null && !selectedIndices.contains(mediaIndex)) { + continue; + } final picture = media[mediaIndex]; final diveIndex = picture['_diveIndex']; diff --git a/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart b/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart index 2e64886e5b..14dfb8e1d2 100644 --- a/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart +++ b/test/features/import_wizard/data/adapters/universal_adapter_photo_test.dart @@ -258,6 +258,53 @@ void main() { expect(seenLongitude, closeTo(-66.084902, 1e-6)); }); + test('leaves out a photo the user deselected in review', () async { + final attached = []; + + final count = await UniversalAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + {'filename': '/p/b.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + ], + resolvedPathByIndex: const {0: '/x/a.jpg', 1: '/x/b.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + selectedIndices: const {0}, + attach: (file, diveId, takenAt, latitude, longitude) async { + attached.add(file.path); + }, + ); + + expect(count, 1); + expect(attached, ['/x/a.jpg']); + }); + + test('a null selection attaches every resolved photo', () async { + var attachCalls = 0; + + final count = await UniversalAdapter.attachResolvedPhotos( + media: [ + {'filename': '/p/a.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + {'filename': '/p/b.jpg', 'offsetSeconds': 0, '_diveIndex': 0}, + ], + resolvedPathByIndex: const {0: '/x/a.jpg', 1: '/x/b.jpg'}, + diveIdByIndex: const {0: 'dive-a'}, + removedDiveIds: const {}, + dives: [ + {'dateTime': DateTime.utc(2025, 1, 15, 10)}, + ], + attach: (file, diveId, takenAt, latitude, longitude) async { + attachCalls++; + }, + ); + + expect(count, 2); + expect(attachCalls, 2); + }); + test( 'ignores a picture whose dive never made it into the import', () async { From 866e37f0a2083500a59a5a017a1a34a13ed54819 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:10:51 -0400 Subject: [PATCH 087/122] fix(buddies): match SQLite null ordering and harden the new tests Addresses the four actionable findings from the third review on #1294. Null dive numbers: the Dart comparator coalesced a null dive number to 0, which is not what the SQL it claims to mirror does. Verified against SQLite directly: ORDER BY n DESC over (5, NULL, 3, 0, -1) returns five, three, zero, neg, null, so NULL sorts last, behind a real zero and behind negatives. Coalescing to 0 instead tied a null with a real 0 and ranked it above -1, which would have reordered the list the repository had already ordered. The comparison is now explicitly null-aware. The comparator was extracted as compareSharedDivesForPreview so it can be tested directly. That matters here: the provider sorts an already-ordered list, so a provider-level test cannot tell a faithful comparator from a sloppy one. Confirmed the new unit test discriminates by reverting to the old form, which yields (three, null, zero, negative) and fails. Overflow handler: it was installed after pumpWidget, so an overflow thrown during the first frame would have escaped it. Both tests now install it before the first frame. Test link scoping: the created_at fixtures updated dive_buddies by dive_id alone, which is correct only while each dive has exactly one buddy link. Both are now scoped to the (dive_id, buddy_id) row under test. Also pinned the SQL side of the null rule with a repository test whose ids are chosen so the id tiebreak would give the opposite order, so only the dive-number rule can satisfy it. Confirmed it fails when the dive_number term is removed. The fifth finding, hydrating the five preview dives concurrently, is not applied; see the PR discussion for the reasoning. --- .../providers/buddy_providers.dart | 35 ++++++++++----- .../repositories/buddy_repository_test.dart | 40 ++++++++++++----- .../pages/buddy_detail_page_test.dart | 8 +++- .../providers/buddy_providers_test.dart | 45 ++++++++++++++++++- 4 files changed, 104 insertions(+), 24 deletions(-) diff --git a/lib/features/buddies/presentation/providers/buddy_providers.dart b/lib/features/buddies/presentation/providers/buddy_providers.dart index d44ff7d76f..3e558f3f97 100644 --- a/lib/features/buddies/presentation/providers/buddy_providers.dart +++ b/lib/features/buddies/presentation/providers/buddy_providers.dart @@ -216,19 +216,34 @@ final divesForBuddyProvider = FutureProvider.family, String>(( } } - // Most recent first, matching the dive list's sort key. The id tiebreak - // mirrors the repository query so a tie on both keys resolves the same way - // here as it does in SQL. - dives.sort((a, b) { - final byTime = b.effectiveEntryTime.compareTo(a.effectiveEntryTime); - if (byTime != 0) return byTime; - final byNumber = (b.diveNumber ?? 0).compareTo(a.diveNumber ?? 0); - if (byNumber != 0) return byNumber; - return a.id.compareTo(b.id); - }); + dives.sort(compareSharedDivesForPreview); return dives; }); +/// Orders two shared dives exactly as `BuddyRepository.getDiveIdsForBuddy` +/// does: newest effective entry time first, then dive number descending, then +/// id ascending. +/// +/// The dive-number step is null-aware rather than coalescing to zero. SQLite +/// sorts NULL below every value, so `ORDER BY dive_number DESC` puts a null +/// dive number *last*, behind a real `0` or a negative one. Coalescing to zero +/// would instead tie a null with a real zero and rank it above a negative, +/// which would reorder the list the repository already ordered. +int compareSharedDivesForPreview(domain.Dive a, domain.Dive b) { + final byTime = b.effectiveEntryTime.compareTo(a.effectiveEntryTime); + if (byTime != 0) return byTime; + + final aNumber = a.diveNumber; + final bNumber = b.diveNumber; + if (aNumber != bNumber) { + if (aNumber == null) return 1; + if (bNumber == null) return -1; + return bNumber.compareTo(aNumber); + } + + return a.id.compareTo(b.id); +} + /// Buddy list notifier for mutations class BuddyListNotifier extends StateNotifier>> { final BuddyRepository _repository; diff --git a/test/features/buddies/data/repositories/buddy_repository_test.dart b/test/features/buddies/data/repositories/buddy_repository_test.dart index 522902fd8c..850809479d 100644 --- a/test/features/buddies/data/repositories/buddy_repository_test.dart +++ b/test/features/buddies/data/repositories/buddy_repository_test.dart @@ -447,11 +447,16 @@ void main() { /// Forces the junction row's link timestamp so link order can be made to /// contradict dive order. - Future setLinkCreatedAt(String diveId, int createdAt) async { + Future setLinkCreatedAt( + String buddyId, + String diveId, + int createdAt, + ) async { final db = DatabaseService.instance.database; await db.customStatement( - 'UPDATE dive_buddies SET created_at = ? WHERE dive_id = ?', - [createdAt, diveId], + 'UPDATE dive_buddies SET created_at = ? ' + 'WHERE dive_id = ? AND buddy_id = ?', + [createdAt, diveId, buddyId], ); } @@ -466,9 +471,9 @@ void main() { await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); } // Link order deliberately inverted relative to dive date order. - await setLinkCreatedAt('old', 9000); - await setLinkCreatedAt('newest', 8000); - await setLinkCreatedAt('middle', 7000); + await setLinkCreatedAt(buddy.id, 'old', 9000); + await setLinkCreatedAt(buddy.id, 'newest', 8000); + await setLinkCreatedAt(buddy.id, 'middle', 7000); final diveIds = await repository.getDiveIdsForBuddy(buddy.id); @@ -485,8 +490,8 @@ void main() { await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); } // Link order deliberately inverted relative to entry time order. - await setLinkCreatedAt('earlier', 9000); - await setLinkCreatedAt('later', 1); + await setLinkCreatedAt(buddy.id, 'earlier', 9000); + await setLinkCreatedAt(buddy.id, 'later', 1); final diveIds = await repository.getDiveIdsForBuddy(buddy.id); @@ -502,8 +507,8 @@ void main() { for (final id in ['lower', 'higher']) { await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); } - await setLinkCreatedAt('lower', 9000); - await setLinkCreatedAt('higher', 1); + await setLinkCreatedAt(buddy.id, 'lower', 9000); + await setLinkCreatedAt(buddy.id, 'higher', 1); final diveIds = await repository.getDiveIdsForBuddy(buddy.id); @@ -511,6 +516,21 @@ void main() { }, ); + test('sorts a null dive number last, as SQLite DESC does', () async { + final buddy = await repository.createBuddy(createTestBuddy(id: 'b1')); + // Ids are chosen so the id tiebreak would give the OPPOSITE order: + // only the dive-number rule can produce the expectation below. + await insertDive('aaa-no-number', diveDateTime: 1000); + await insertDive('zzz-has-number', diveDateTime: 1000, diveNumber: 3); + for (final id in ['aaa-no-number', 'zzz-has-number']) { + await repository.addBuddyToDive(id, buddy.id, DiveRole.buddyId); + } + + final diveIds = await repository.getDiveIdsForBuddy(buddy.id); + + expect(diveIds, equals(['zzz-has-number', 'aaa-no-number'])); + }); + test( 'is deterministic when timestamp and dive number both tie', () async { diff --git a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart index acd3c6c227..6c37c09ff1 100644 --- a/test/features/buddies/presentation/pages/buddy_detail_page_test.dart +++ b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart @@ -181,6 +181,9 @@ void main() { tester.view.resetDevicePixelRatio(); }); + // Installed before the first frame: an overflow thrown during + // pumpWidget would otherwise escape the handler. + _ignoreOverflowErrors(); await tester.pumpWidget( ProviderScope( overrides: [ @@ -201,7 +204,6 @@ void main() { ), ), ); - _ignoreOverflowErrors(); await tester.pumpAndSettle(); // Should show bottomTime formatted as minutes in dive history @@ -239,6 +241,9 @@ void main() { tester.view.resetDevicePixelRatio(); }); + // Installed before the first frame: an overflow thrown during + // pumpWidget would otherwise escape the handler. + _ignoreOverflowErrors(); await tester.pumpWidget( ProviderScope( overrides: [ @@ -260,7 +265,6 @@ void main() { ), ), ); - _ignoreOverflowErrors(); await tester.pumpAndSettle(); expect( diff --git a/test/features/buddies/presentation/providers/buddy_providers_test.dart b/test/features/buddies/presentation/providers/buddy_providers_test.dart index 4b4153588e..887ae74af0 100644 --- a/test/features/buddies/presentation/providers/buddy_providers_test.dart +++ b/test/features/buddies/presentation/providers/buddy_providers_test.dart @@ -8,6 +8,8 @@ import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + as domain; import 'package:submersion/features/dive_roles/domain/entities/dive_role.dart'; import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; import 'package:submersion/features/divers/domain/entities/diver.dart'; @@ -245,6 +247,44 @@ void main() { // arbitrary five dives because the ids arrived in `dive_buddies.created_at` // order (when the link was written) and only the surviving five were sorted // by dive date. A dive from a previous year outranked the newest one. + // The provider re-sorts a list the repository has already ordered, so a + // provider-level test cannot tell a faithful comparator from a sloppy one. + // These exercise the comparator directly instead. + group('compareSharedDivesForPreview (#982)', () { + domain.Dive diveWith({required String id, int? diveNumber}) => domain.Dive( + id: id, + diveNumber: diveNumber, + dateTime: DateTime(2026, 3, 28), + ); + + List sorted(List dives) => + (dives.toList()..sort(compareSharedDivesForPreview)) + .map((d) => d.id) + .toList(); + + test('places a null dive number last, behind zero and negatives', () { + // SQLite sorts NULL below every value, so DESC puts it last. Coalescing + // null to 0 would rank it above -1 and tie it with a real 0. + final dives = [ + diveWith(id: 'null', diveNumber: null), + diveWith(id: 'negative', diveNumber: -1), + diveWith(id: 'zero', diveNumber: 0), + diveWith(id: 'three', diveNumber: 3), + ]; + + expect(sorted(dives), equals(['three', 'zero', 'negative', 'null'])); + }); + + test('falls back to id when dive numbers are both null', () { + final dives = [ + diveWith(id: 'zzz', diveNumber: null), + diveWith(id: 'aaa', diveNumber: null), + ]; + + expect(sorted(dives), equals(['aaa', 'zzz'])); + }); + }); + group('divesForBuddyProvider ordering (#982)', () { test('previews the five newest dives, newest first', () async { final diver = await seedCurrentDiver(); @@ -264,8 +304,9 @@ void main() { ); await buddyRepo.addBuddyToDive(diveIds[i], buddy.id, DiveRole.buddyId); await database.customStatement( - 'UPDATE dive_buddies SET created_at = ? WHERE dive_id = ?', - [diveIds.length - i, diveIds[i]], + 'UPDATE dive_buddies SET created_at = ? ' + 'WHERE dive_id = ? AND buddy_id = ?', + [diveIds.length - i, diveIds[i], buddy.id], ); } From afc6f070d8327429df275ad73e9fa1669be8431a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:11:20 -0400 Subject: [PATCH 088/122] fix(import): break candidate ties on id so devices agree (#1288) `matchImportedComputer` takes the first candidate that matches, and its contract is that candidates arrive in a deterministic preference order. The repository ordered on `updatedAt` alone, so rows sharing a timestamp came back in whatever order SQLite chose and two devices could attribute the same dives to different computer rows. Mirrors the backfill helper's `ORDER BY updated_at DESC, id`. Also pins the deliberate asymmetry in the best-effort attribution path: when the dive-level write fails, the `dive_data_sources.computer_id` stamp is kept on purpose, because the #1064 beforeOpen heal adopts `dives.computer_id` from exactly that column. Leaving it is what recovers the attribution on the next open, so clearing it for symmetry would discard the recovery. The log line said the dive kept only its snapshot, which understated what survives. --- .../data/services/uddf_entity_importer.dart | 8 ++- .../dive_computer_repository_impl.dart | 9 ++- ...y_importer_computer_registration_test.dart | 56 +++++++++++++++++++ .../dive_computer_repository_impl_test.dart | 30 ++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index 3c3e9d4166..7194f6fe3f 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -1695,6 +1695,11 @@ class UddfEntityImporter { // Best-effort for the same reason as the registration above, and more // pressingly: the dive is already committed, so throwing here would // abort the loop and leave a half-imported logbook behind. + // + // The provenance row below is still stamped on failure, deliberately. + // The #1064 beforeOpen heal adopts dives.computer_id from exactly that + // column, so leaving it is what recovers the attribution on the next + // open; clearing it for symmetry would discard the recovery. try { await repos.diveComputerRepository?.attributeDiveToComputer( diveId: diveId, @@ -1703,7 +1708,8 @@ class UddfEntityImporter { } catch (e, stackTrace) { _log.error( 'Failed to attribute imported dive $diveId to computer ' - '$computerId; the dive keeps its model snapshot only', + '$computerId; the data source keeps the link, so the beforeOpen ' + 'self-heal will adopt it on the next open', error: e, stackTrace: stackTrace, ); diff --git a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart index 876ed5b4f5..4dc4a41e0d 100644 --- a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart @@ -1671,8 +1671,15 @@ class DiveComputerRepository { final normalizedModel = normalizeComputerIdentityPart(model); if (normalizedModel.isEmpty) return null; + // Most recently updated first, ties broken on id: matchImportedComputer + // takes the first candidate that matches, so an unstable order would let + // two devices attribute the same dives to different rows. Mirrors the + // backfill's `ORDER BY updated_at DESC, id`. final query = _db.select(_db.diveComputers) - ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]); + ..orderBy([ + (t) => OrderingTerm.desc(t.updatedAt), + (t) => OrderingTerm.asc(t.id), + ]); final normalizedDiverId = diverId?.trim(); if (normalizedDiverId != null && normalizedDiverId.isNotEmpty) { query.where((t) => t.diverId.equals(normalizedDiverId)); diff --git a/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart b/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart index 4191556935..3c3983bdb6 100644 --- a/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart +++ b/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart @@ -267,6 +267,52 @@ void main() { expect(dives.single.diveComputerModel, 'Perdix 2'); }); + test( + 'a failed attribution leaves the provenance link for the self-heal', + () async { + // Deliberately NOT symmetric: when the dive-level write fails, the + // dive_data_sources.computer_id stamp is kept on purpose. The #1064 + // beforeOpen heal adopts dives.computer_id from exactly that column, so + // the breadcrumb is what recovers the attribution on the next open. + // Clearing it for tidiness would throw the recovery away. + final result = await importer.import( + data: UddfImportResult( + dives: [diveEntry(day: 1, model: 'Perdix 2', serial: 'SN-1')], + ), + selections: const UddfImportSelections(dives: {0}), + repositories: ImportRepositories( + tripRepository: TripRepository(), + equipmentRepository: EquipmentRepository(), + equipmentSetRepository: EquipmentSetRepository(), + buddyRepository: BuddyRepository(), + diveCenterRepository: DiveCenterRepository(), + certificationRepository: CertificationRepository(), + tagRepository: TagRepository(), + diveTypeRepository: DiveTypeRepository(), + siteRepository: SiteRepository(), + diveRepository: DiveRepository(), + tankPressureRepository: TankPressureRepository(), + courseRepository: CourseRepository(), + diveComputerRepository: _AttributionFailingComputerRepository(), + ), + diverId: diverId, + ); + + expect(result.dives, 1); + final computer = (await db.select(db.diveComputers).get()).single; + expect((await db.select(db.dives).get()).single.computerId, isNull); + expect( + (await db.select(db.diveDataSources).get()).single.computerId, + computer.id, + ); + + // The next app open recovers it. + await db.backfillDiveComputerIdsForTest(); + + expect((await db.select(db.dives).get()).single.computerId, computer.id); + }, + ); + test('adopts a computer already registered by a download', () async { // The download path stores vendor and product separately; the file // carries them as one string. The dive must join the existing device, @@ -308,3 +354,13 @@ class _FailingComputerRepository extends DiveComputerRepository { String? diverId, }) async => throw StateError('registry unavailable'); } + +/// Registers normally but cannot write the dive-level attribution, to pin +/// that the provenance link survives for the #1064 self-heal. +class _AttributionFailingComputerRepository extends DiveComputerRepository { + @override + Future attributeDiveToComputer({ + required String diveId, + required String computerId, + }) async => throw StateError('dives table unavailable'); +} diff --git a/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart b/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart index 77e0833a1a..87237e6466 100644 --- a/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart +++ b/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart @@ -1363,6 +1363,36 @@ void main() { expect(await db.select(db.diveComputers).get(), hasLength(1)); }); + test('breaks a tie on id so every device resolves alike', () async { + // matchImportedComputer's contract is that candidates arrive in a + // deterministic preference order. Ordering on updatedAt alone leaves + // same-timestamp rows in whatever order SQLite happens to return, so + // two devices could attribute the same dives to different rows. + await insertComputer( + id: 'dc-z', + diverId: 'diver-1', + manufacturer: null, + model: 'Perdix 2', + serialNumber: null, + ); + await insertComputer( + id: 'dc-a', + diverId: 'diver-1', + manufacturer: null, + model: 'Perdix 2', + serialNumber: null, + ); + // Same updatedAt on both, which insertComputer already guarantees. + await db.customStatement('UPDATE dive_computers SET updated_at = 1000'); + + final computer = await repository.findOrRegisterImportedComputer( + model: 'Perdix 2', + diverId: 'diver-1', + ); + + expect(computer!.id, 'dc-a'); + }); + test('registers nothing when the model is blank', () async { final computer = await repository.findOrRegisterImportedComputer( model: ' ', From 7c6fba4b4bc46309babe3c5f4dd95c283a6ec405 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:26:01 -0400 Subject: [PATCH 089/122] fix(sac): treat a zero parsed volume as unreported; drop the any-tank guard on the SAC hint Review follow-ups on #1298: - Reparse only overwrites a stored cylinder volume with a positive parsed one. The native bridges already map a libdc volume of 0 to null, but the Dart layer treats 0 as "missing" everywhere else in the tank code, so it must not clobber a stored size either. - The Details SAC row no longer hides itself when some other tank has a volume. A pressure SAC with no volumetric one means no cylinder with a pressure drop has a volume, whatever a stage bottle carries, so the fallback and hint apply. Dive.hasCylinderVolume had no other consumer and is removed. --- .../data/services/reparse_service.dart | 5 +- .../dive_log/domain/entities/dive.dart | 5 -- .../presentation/pages/dive_detail_page.dart | 10 ++-- .../data/services/reparse_service_test.dart | 51 +++++++++++++++++++ .../pages/dive_detail_sac_row_test.dart | 28 ++++++++++ 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/lib/features/dive_computer/data/services/reparse_service.dart b/lib/features/dive_computer/data/services/reparse_service.dart index 411695eeb8..4b418d4220 100644 --- a/lib/features/dive_computer/data/services/reparse_service.dart +++ b/lib/features/dive_computer/data/services/reparse_service.dart @@ -685,7 +685,10 @@ class ReparseService { // Computers report pressure, not cylinder size: a volume the // parse lacks was entered by the diver (or filled from the // default preset), so only overwrite it with a reported one. - volume: Value.absentIfNull(tank.volumeLiters), + // Zero means "unreported" throughout the tank code. + volume: (tank.volumeLiters ?? 0) > 0 + ? Value(tank.volumeLiters) + : const Value.absent(), workingPressure: const Value.absent(), startPressure: Value(tank.startPressure), endPressure: Value(tank.endPressure), diff --git a/lib/features/dive_log/domain/entities/dive.dart b/lib/features/dive_log/domain/entities/dive.dart index f6fa96714a..ef844b7957 100644 --- a/lib/features/dive_log/domain/entities/dive.dart +++ b/lib/features/dive_log/domain/entities/dive.dart @@ -415,11 +415,6 @@ class Dive extends Equatable { return totalGasLiters / minutes / avgPressureBar; } - /// Whether any cylinder carries a usable volume, the one input volumetric - /// (L/min) SAC needs and dive computers do not report (issue #386). - bool get hasCylinderVolume => - tanks.any((t) => t.volume != null && t.volume! > 0); - /// Air consumption rate in pressure units per minute (bar/min or psi/min) /// This is a simpler calculation that doesn't require tank volume. /// It calculates the average pressure drop per minute adjusted for depth. diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 7b1f47f216..b33dc3804d 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -4081,11 +4081,11 @@ class _DiveDetailPageState extends ConsumerState { } // No cylinder volume (the norm for dive-computer downloads): show the // pressure lane and say why, rather than hiding the row and leaving - // the L/min preference looking broken (issue #386). With no pressure - // data either there is nothing to fall back to. - if (dive.hasCylinderVolume || dive.sacPressure == null) { - return const SizedBox.shrink(); - } + // the L/min preference looking broken (issue #386). A pressure SAC + // with no volumetric one means no cylinder with a pressure drop has a + // volume, whatever a stage bottle carries, so the hint is accurate. + // With no pressure data either there is nothing to fall back to. + if (dive.sacPressure == null) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/test/features/dive_computer/data/services/reparse_service_test.dart b/test/features/dive_computer/data/services/reparse_service_test.dart index 32f81f6246..bf49b58fff 100644 --- a/test/features/dive_computer/data/services/reparse_service_test.dart +++ b/test/features/dive_computer/data/services/reparse_service_test.dart @@ -1922,6 +1922,57 @@ void main() { expect(tank.volume, 12.0); }); + test( + 'DiveTanks carry-over treats a zero parsed volume as unreported', + () async { + // The native bridges already map a libdc volume of 0 to null, but the + // Dart layer must not rely on that: 0 means "missing" everywhere else + // in the tank code, so it must not clobber a stored size either. + await insertDive('dive-1'); + await insertComputer('comp-1'); + await insertSource( + id: 'src-1', + diveId: 'dive-1', + computerId: 'comp-1', + isPrimary: true, + ); + await db + .into(db.diveTanks) + .insert( + const DiveTanksCompanion( + id: Value('tank-0'), + diveId: Value('dive-1'), + volume: Value(12.0), + o2Percent: Value(21.0), + hePercent: Value(0.0), + tankOrder: Value(0), + ), + ); + + await service.applyParsedUpdate( + diveId: 'dive-1', + sourceRowId: 'src-1', + parsed: makeParsedDive( + tanks: [ + pigeon.TankInfo(index: 0, gasMixIndex: 0, volumeLiters: 0.0), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 21.0, hePercent: 0.0), + ], + ), + descriptorVendor: null, + descriptorProduct: null, + descriptorModel: null, + libdivecomputerVersion: null, + ); + + final tank = await (db.select( + db.diveTanks, + )..where((t) => t.diveId.equals('dive-1'))).getSingle(); + expect(tank.volume, 12.0); + }, + ); + test('non-primary source skips tank carry-over', () async { // Arrange: two sources, re-parse the non-primary one await insertDive('dive-1'); diff --git a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart index 3cae2e65ef..a05328a608 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart @@ -157,6 +157,34 @@ void main() { ); }); + testWidgets('still falls back when only a stage bottle has a volume', ( + tester, + ) async { + // The back gas (the tank sacPressure reads) has pressures but no size; + // a stage carries a size but no pressures. No cylinder can yield L/min, + // so the row must fall back and the hint must still point at volume. + final dive = reportedDive(volume: null).copyWith( + tanks: [ + ...reportedDive(volume: null).tanks, + const DiveTank( + id: 'stage-1', + volume: 11.1, + gasMix: GasMix(o2: 50.0, he: 0.0), + role: TankRole.stage, + order: 1, + ), + ], + ); + await pumpWith( + tester, + const AppSettings(sacUnit: SacUnit.litersPerMin), + dive: dive, + ); + + expect(find.text('1.5 bar/min'), findsOneWidget); + expect(find.byType(SacVolumeHint), findsOneWidget); + }); + testWidgets('shows no hint in the pressure lane', (tester) async { await pumpWith( tester, From 38e020b56fd15c8aedb02ad2939d66bcda989540 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:26:08 -0400 Subject: [PATCH 090/122] fix(sites): keep geocoder outage on LocationResult; never reset a running backfill (#1187) Review follow-ups on #1300. LocationResult.place rebuilt a PlaceLookup from its string fields and lost networkFailed, so a caller of getCurrentLocation could not tell "offline" from "nothing there"; it now carries a geocodeUnavailable flag through. The backfill flow called reset() before start(), which defeated the notifier's guard against overlapping runs when the progress dialog had been popped mid-run; the reset is gone and a flow opened mid-run reattaches the progress dialog. --- lib/core/services/location_service.dart | 7 +++++ .../site_location_backfill_dialog.dart | 13 +++++++++- test/core/services/location_service_test.dart | 26 +++++++++++++++++++ .../site_location_backfill_dialog_test.dart | 18 +++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index a095542ed9..a4ad4a69f1 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -23,6 +23,10 @@ class LocationResult { final String? locality; final String? bodyOfWater; + /// True when the position was found but the geocoder could not be + /// reached, so the empty place fields mean "unknown", not "nothing there". + final bool geocodeUnavailable; + const LocationResult({ required this.latitude, required this.longitude, @@ -31,6 +35,7 @@ class LocationResult { this.region, this.locality, this.bodyOfWater, + this.geocodeUnavailable = false, }); /// The geocoded part of this result, in the shape the site form consumes. @@ -39,6 +44,7 @@ class LocationResult { region: region, locality: locality, bodyOfWater: bodyOfWater, + networkFailed: geocodeUnavailable, ); @override @@ -254,6 +260,7 @@ class LocationService { region: place.region, locality: place.locality, bodyOfWater: place.bodyOfWater, + geocodeUnavailable: place.networkFailed, ); } catch (e, stackTrace) { _log.error( diff --git a/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart b/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart index ab479626bb..48ab1334bf 100644 --- a/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart +++ b/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart @@ -17,6 +17,17 @@ Future showSiteLocationBackfillFlow( final notifier = ref.read(siteLocationBackfillProvider.notifier); final messenger = ScaffoldMessenger.of(context); + // A run already in progress (the progress dialog was popped by a system + // back gesture, say) is shown again rather than asked about twice. + if (ref.read(siteLocationBackfillProvider) is BackfillRunning) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _BackfillProgressDialog(), + ); + return; + } + final count = await notifier.countCandidates(); if (!context.mounted) return; if (count == 0) { @@ -46,7 +57,7 @@ Future showSiteLocationBackfillFlow( ); if (confirmed != true || !context.mounted) return; - notifier.reset(); + // No reset here: start() is the only guard against overlapping runs. final run = notifier.start(); await showDialog( context: context, diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index cc02b85bd0..1a3cfb39cb 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -490,6 +490,32 @@ void main() { }); }); + group('LocationResult.place', () { + test('carries the geocoder outage through to the site form', () { + const result = LocationResult( + latitude: 47.0, + longitude: 8.4, + geocodeUnavailable: true, + ); + expect(result.place.networkFailed, isTrue); + expect(result.place.isEmpty, isTrue); + }); + + test('a geocoded result is a plain lookup', () { + const result = LocationResult( + latitude: 47.0, + longitude: 8.4, + country: 'Switzerland', + locality: 'Weggis', + bodyOfWater: 'Lake Lucerne', + ); + expect(result.place.networkFailed, isFalse); + expect(result.place.country, 'Switzerland'); + expect(result.place.locality, 'Weggis'); + expect(result.place.bodyOfWater, 'Lake Lucerne'); + }); + }); + group('body of water (issue #1187)', () { Map address() => { 'address': { diff --git a/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart b/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart index ec000ca00d..08ae603535 100644 --- a/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart +++ b/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart @@ -37,6 +37,9 @@ class _ScriptedBackfill extends StateNotifier @override void cancel() => cancelled = true; + /// Puts the notifier mid-run without going through [start]. + void pretendRunning() => state = const BackfillRunning(done: 1, total: 3); + @override void reset() => state = const BackfillIdle(); @@ -178,4 +181,19 @@ void main() { findsOneWidget, ); }); + + testWidgets('reopening the flow mid-run shows progress, not a new run', ( + tester, + ) async { + final notifier = _ScriptedBackfill(candidates: 3, script: const []); + await tester.pumpWidget(host(notifier)); + notifier.pretendRunning(); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect(find.text('Filling in location details'), findsOneWidget); + expect(find.text('1 of 3'), findsOneWidget); + expect(find.text('Fill in missing location details?'), findsNothing); + expect(notifier.startCalls, 0); + }); } From 9134adc12f31ee8428823f8eb2005521245408df Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:43:12 -0400 Subject: [PATCH 091/122] chore(sites): normalize place name language case and whitespace; refresh Nominatim fake doc (#1187) --- lib/core/constants/place_name_language.dart | 11 ++++++++--- test/core/constants/place_name_language_test.dart | 5 +++++ test/helpers/fake_nominatim.dart | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/core/constants/place_name_language.dart b/lib/core/constants/place_name_language.dart index 7a6b0d7057..9e38260c04 100644 --- a/lib/core/constants/place_name_language.dart +++ b/lib/core/constants/place_name_language.dart @@ -25,7 +25,12 @@ abstract final class PlaceNameLanguage { ]; /// A supported code, or [defaultCode] for anything else. A synced peer on a - /// newer build could send a code this build does not know. - static String normalize(String? code) => - code != null && supportedCodes.contains(code) ? code : defaultCode; + /// newer build could send a code this build does not know, and a hand-edited + /// or padded value should not silently fall back to English. + static String normalize(String? code) { + final cleaned = code?.trim().toLowerCase(); + return cleaned != null && supportedCodes.contains(cleaned) + ? cleaned + : defaultCode; + } } diff --git a/test/core/constants/place_name_language_test.dart b/test/core/constants/place_name_language_test.dart index 3eed73450e..9a6fd9fb87 100644 --- a/test/core/constants/place_name_language_test.dart +++ b/test/core/constants/place_name_language_test.dart @@ -26,6 +26,11 @@ void main() { expect(PlaceNameLanguage.normalize('de'), 'de'); }); + test('normalize tolerates case and whitespace', () { + expect(PlaceNameLanguage.normalize(' DE '), 'de'); + expect(PlaceNameLanguage.normalize('Zh'), 'zh'); + }); + test('normalize falls back to English for unknown, null or blank', () { expect(PlaceNameLanguage.normalize('xx'), 'en'); expect(PlaceNameLanguage.normalize(null), 'en'); diff --git a/test/helpers/fake_nominatim.dart b/test/helpers/fake_nominatim.dart index 1d2aaf8172..6b911413a4 100644 --- a/test/helpers/fake_nominatim.dart +++ b/test/helpers/fake_nominatim.dart @@ -14,7 +14,8 @@ import 'dart:io'; /// The geocoding paths talk to Nominatim through `dart:io HttpClient`, so the /// only seam that does not require a real socket is [HttpOverrides]. Every /// request the service makes is captured here so the tests can assert on the -/// English pin (#214) that lives in the URI *and* in the request headers. +/// language the caller asked for (issue #1187), which must reach both the URI +/// and the request headers. class FakeNominatim { FakeNominatim({this.statusCode = 200, this.body = '{}', this.bodyFor}); From 9b5e2f0dd4e37a74634a59467f08910494dfe959 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 02:51:05 -0400 Subject: [PATCH 092/122] chore(sites): clarify normalize doc; restore the Nominatim throttle after tests (#1187) --- lib/core/constants/place_name_language.dart | 7 ++++--- test/core/services/location_service_test.dart | 1 + .../data/services/uddf_entity_importer_language_test.dart | 1 + test/integration/uddf_round_trip_test.dart | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/core/constants/place_name_language.dart b/lib/core/constants/place_name_language.dart index 9e38260c04..f876c74e01 100644 --- a/lib/core/constants/place_name_language.dart +++ b/lib/core/constants/place_name_language.dart @@ -24,9 +24,10 @@ abstract final class PlaceNameLanguage { 'zh', ]; - /// A supported code, or [defaultCode] for anything else. A synced peer on a - /// newer build could send a code this build does not know, and a hand-edited - /// or padded value should not silently fall back to English. + /// A supported code, or [defaultCode] for anything else: null, blank, or a + /// code this build does not know (a synced peer on a newer build could send + /// one). Case and surrounding whitespace are forgiven first, so a padded or + /// upper-cased copy of a supported code still counts as that code. static String normalize(String? code) { final cleaned = code?.trim().toLowerCase(); return cleaned != null && supportedCodes.contains(cleaned) diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index 1a3cfb39cb..b429e87ba8 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -21,6 +21,7 @@ void main() { setUp(() { LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + addTearDown(() => LocationService.throttle = NominatimThrottle()); }); group('Nominatim URIs pin English results (#214)', () { diff --git a/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart b/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart index 5f4a1f81a7..62e6b0edd1 100644 --- a/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart +++ b/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart @@ -18,6 +18,7 @@ void main() { setUp(() { LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + addTearDown(() => LocationService.throttle = NominatimThrottle()); sites = MockSiteRepository(); when( sites.getAllSites(diverId: anyNamed('diverId')), diff --git a/test/integration/uddf_round_trip_test.dart b/test/integration/uddf_round_trip_test.dart index 07fc8d5039..59f9ef0dc2 100644 --- a/test/integration/uddf_round_trip_test.dart +++ b/test/integration/uddf_round_trip_test.dart @@ -74,6 +74,7 @@ void main() { setUp(() async { // Nominatim spacing would add a real second per geocode here. LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + addTearDown(() => LocationService.throttle = NominatimThrottle()); // Create fresh in-memory database for each test testDb = AppDatabase(NativeDatabase.memory()); DatabaseService.instance.setTestDatabase(testDb); From 06995226530f250dee535546ac67c6595eaf0521 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 03:00:14 -0400 Subject: [PATCH 093/122] test(sync): cover the conflict display patch fully Codecov put the patch at 77.94% against an 80% target. Reproduced locally to the same figure, which pointed at the label table: 47 of its 92 instrumented lines were cold, because the switch has an arm per entity type and the tests exercised four of them. Add a table-driven label test that asserts the expected string for every target, plus an invariant that the table covers ConflictReferenceResolver's own set of targets. A foreign key added later without a label arm now fails that test instead of silently rendering a humanized entity name; verified by adding a fake target and watching it fail. That needed a public targetTypes getter over the map the resolver already holds. Also cover the branches that had no test at all, one of which was a real behavior problem the review raised. A quality-finding row that cannot be read (malformed params, or a category from a newer schema) hid detectorId and params anyway, so the user was left with strictly less than the raw preview had given them. Those columns are now dropped only when the localized sentence that replaces them was actually built. The remaining new tests pin contracts worth stating: a conflict whose local row is already gone still reaches the dialog, and a reference lookup that throws degrades to an unresolved preview rather than dropping the conflict, which would leave it permanently unresolvable. Patch coverage is now 100% (269/269). --- .../services/sync/conflict_reference.dart | 8 + .../widgets/conflict_data_preview.dart | 15 +- .../sync/sync_conflict_resolution_test.dart | 67 +++++ .../conflict_reference_labels_test.dart | 234 ++++++++++++++++++ .../conflict_resolution_dialog_test.dart | 91 +++++++ 5 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 test/features/settings/presentation/widgets/conflict_reference_labels_test.dart diff --git a/lib/core/services/sync/conflict_reference.dart b/lib/core/services/sync/conflict_reference.dart index 1b907065a2..4c62a98796 100644 --- a/lib/core/services/sync/conflict_reference.dart +++ b/lib/core/services/sync/conflict_reference.dart @@ -136,6 +136,14 @@ class ConflictReferenceResolver { 'startDate', ]; + /// Every entity type a foreign key can point at. Exposed so the dialog's + /// label table can prove it covers the whole set rather than silently + /// falling back to a humanized entity name for a target added later. + static Set get targetTypes => { + ..._defaultTargets.values, + for (final columns in _targetOverrides.values) ...columns.values, + }; + /// The entity type [field] on an [entityType] record points at, or null when /// the column is not a foreign key. static String? targetTypeFor(String entityType, String field) => diff --git a/lib/features/settings/presentation/widgets/conflict_data_preview.dart b/lib/features/settings/presentation/widgets/conflict_data_preview.dart index ece458143b..1b5347f7cd 100644 --- a/lib/features/settings/presentation/widgets/conflict_data_preview.dart +++ b/lib/features/settings/presentation/widgets/conflict_data_preview.dart @@ -34,7 +34,8 @@ const _alwaysHidden = { /// Fields an entity renders some other way, and so must not repeat as raw /// columns. Quality findings store facts, not prose: `detectorId` and `params` -/// become a localized sentence, so the raw values would only be noise. +/// become a localized sentence, so the raw values would only be noise -- but +/// only once that sentence has actually been built. const _entityHidden = >{ 'qualityFindings': {'detectorId', 'detectorVersion', 'params', 'category'}, }; @@ -127,9 +128,16 @@ List conflictPreviewRows({ required Map data, required List references, }) { + final message = entityType == 'qualityFindings' + ? _findingMessage(l10n, findingFormatters, data) + : null; + final hidden = { ..._alwaysHidden, - ...?_entityHidden[entityType], + // Only drop the columns a rendered sentence replaced. If the row could + // not be read, hiding them too would leave the user with less than the + // raw preview gave them. + if (message != null) ...?_entityHidden[entityType], for (final reference in references) reference.field, }; final preferred = _preferredScalars(data, hidden); @@ -150,9 +158,6 @@ List conflictPreviewRows({ ), ]; - final message = entityType == 'qualityFindings' - ? _findingMessage(l10n, findingFormatters, data) - : null; if (message != null) { rows.add(( label: l10n.settings_conflict_ref_finding, diff --git a/test/core/services/sync/sync_conflict_resolution_test.dart b/test/core/services/sync/sync_conflict_resolution_test.dart index 281fff7ae7..9e5a4f56e5 100644 --- a/test/core/services/sync/sync_conflict_resolution_test.dart +++ b/test/core/services/sync/sync_conflict_resolution_test.dart @@ -113,6 +113,57 @@ void main() { ); }); + test('surfaces a conflict whose local row is already gone', () async { + // Nothing local to fetch, so there are no local fields to resolve. The + // conflict still has to reach the dialog or the user cannot act on it. + await raiseConflict('dives', 'd-vanished', { + 'id': 'd-vanished', + 'maxDepth': 42.0, + }); + + final conflict = (await buildService().getConflicts()).single; + + expect(conflict.localData, isEmpty); + expect(conflict.localReferences, isEmpty); + expect(conflict.recordId, 'd-vanished'); + }); + + test('keeps a conflict when a reference lookup fails', () async { + final serializer = SyncDataSerializer(); + await serializer.upsertRecord('tags', { + 'id': 'tag-1', + 'name': 'Wreck', + 'createdAt': 1000, + 'updatedAt': 1000, + }); + await seedDive('d-reffail', 10); + await serializer.upsertRecord('diveTags', { + 'id': 'dt-fail', + 'diveId': 'd-reffail', + 'tagId': 'tag-1', + 'createdAt': 1000, + }); + await raiseConflict('diveTags', 'dt-fail', { + 'id': 'dt-fail', + 'diveId': 'd-reffail', + 'tagId': 'tag-1', + 'createdAt': 2000, + }); + + final service = SyncService( + syncRepository: SyncRepository(), + serializer: _TagLookupFailsSerializer(), + cloudProvider: cloud, + ); + final conflict = (await service.getConflicts()).single; + + // Degrading to an unresolved preview is the point: dropping the + // conflict would leave it permanently unresolvable. + expect(conflict.recordId, 'dt-fail'); + expect(conflict.localReferences, isEmpty); + expect(conflict.remoteReferences, isEmpty); + }); + test( 'keepLocal preserves the local value and clears the conflict', () async { @@ -261,3 +312,19 @@ void main() { ); }); } + +/// Fails only when a reference is resolved, never when the conflicting row +/// itself is loaded, so the failure lands in the reference-resolution step +/// rather than the outer conflict parse. +class _TagLookupFailsSerializer extends SyncDataSerializer { + @override + Future?> fetchRecord( + String entityType, + String recordId, + ) { + if (entityType == 'tags') { + throw StateError('simulated lookup failure for $recordId'); + } + return super.fetchRecord(entityType, recordId); + } +} diff --git a/test/features/settings/presentation/widgets/conflict_reference_labels_test.dart b/test/features/settings/presentation/widgets/conflict_reference_labels_test.dart new file mode 100644 index 0000000000..650e839125 --- /dev/null +++ b/test/features/settings/presentation/widgets/conflict_reference_labels_test.dart @@ -0,0 +1,234 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/sync/conflict_reference.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/conflict_reference_labels.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Coverage for how the conflict dialog labels and renders a resolved +/// reference (#1031). The label table is the part of this feature that a new +/// foreign key silently falls out of, so it is asserted against the resolver's +/// own set of targets rather than a hand-kept list. +void main() { + late AppLocalizations l10n; + const units = UnitFormatter(AppSettings()); + + setUpAll(() async { + l10n = await AppLocalizations.delegate.load(const Locale('en')); + }); + + ConflictReference ref({ + String field = 'someId', + String targetType = 'dives', + String recordId = 'rec-1', + bool exists = true, + String? name, + DateTime? timestamp, + }) => ConflictReference( + field: field, + targetType: targetType, + recordId: recordId, + exists: exists, + name: name, + timestamp: timestamp, + ); + + group('labels', () { + /// The label every target type is expected to carry. Kept explicit so a + /// wrong switch arm fails loudly rather than reading plausibly. + const expected = { + 'dives': 'Dive', + 'diveSites': 'Dive site', + 'tags': 'Tag', + 'diveTypes': 'Dive type', + 'divers': 'Diver', + 'buddies': 'Buddy', + 'equipment': 'Equipment', + 'equipmentSets': 'Equipment set', + 'cylinderConfigs': 'Cylinder configuration', + 'diveComputers': 'Dive computer', + 'diveDataSources': 'Data source', + 'diveTanks': 'Tank', + 'divePlanTanks': 'Planned tank', + 'divePlans': 'Dive plan', + 'trips': 'Trip', + 'diveCenters': 'Dive center', + 'courses': 'Course', + 'certifications': 'Certification', + 'courseRequirements': 'Course requirement', + 'serviceKinds': 'Service type', + 'species': 'Species', + 'sightings': 'Sighting', + 'media': 'Media', + 'mediaSubscriptions': 'Media subscription', + 'connectedAccounts': 'Connected account', + 'preDiveSessions': 'Pre-dive checklist run', + 'checklistTemplates': 'Checklist template', + 'preDiveChecklistTemplates': 'Pre-dive checklist template', + }; + + test('covers every entity type a foreign key can point at', () { + expect( + expected.keys.toSet(), + ConflictReferenceResolver.targetTypes, + reason: + 'a new foreign-key target needs its own label arm and an entry ' + 'here, or the dialog falls back to a humanized entity name', + ); + }); + + test('names each target type', () { + for (final entry in expected.entries) { + expect( + conflictReferenceLabel(l10n, ref(targetType: entry.key)), + entry.value, + reason: entry.key, + ); + } + }); + + test('lets a column name override its target type', () { + // These columns all point at an entity that already has a label, but + // mean something more specific than "another one of those". + expect( + conflictReferenceLabel(l10n, ref(field: 'relatedDiveId')), + 'Related dive', + ); + expect( + conflictReferenceLabel(l10n, ref(field: 'linkedDiveId')), + 'Linked dive', + ); + expect( + conflictReferenceLabel(l10n, ref(field: 'sourceDiveId')), + 'Source dive', + ); + expect( + conflictReferenceLabel( + l10n, + ref(field: 'instructorId', targetType: 'buddies'), + ), + 'Instructor', + ); + expect( + conflictReferenceLabel( + l10n, + ref(field: 'signerId', targetType: 'buddies'), + ), + 'Signed by', + ); + }); + + test('falls back to a readable entity name for an unknown target', () { + expect( + conflictReferenceLabel(l10n, ref(targetType: 'somethingNewEntirely')), + 'Something New Entirely', + ); + }); + }); + + group('values', () { + test('pairs a name with a date when the record has both', () { + final value = conflictReferenceValue( + l10n, + units, + ref(name: 'Blue Hole', timestamp: DateTime(2026, 3, 28)), + ); + expect(value, startsWith('Blue Hole (')); + expect(value, endsWith(')')); + }); + + test('uses the name alone when there is no date', () { + expect(conflictReferenceValue(l10n, units, ref(name: 'Wreck')), 'Wreck'); + }); + + test('uses the date alone when there is no name', () { + final value = conflictReferenceValue( + l10n, + units, + ref(timestamp: DateTime(2026, 3, 28)), + ); + expect(value, isNot(contains('('))); + expect(value, isNotEmpty); + }); + + test('says so when the record is not in the library', () { + expect( + conflictReferenceValue(l10n, units, ref(exists: false)), + 'No longer in this library', + ); + }); + + test('falls back to a short id for a record with no anchor', () { + expect( + conflictReferenceValue( + l10n, + units, + ref(recordId: 'aabbccdd-1111-2222-3333-444455556666'), + ), + '#aabbccdd', + ); + }); + }); + + group('summary', () { + test('joins the names of the records a row points at', () { + expect( + conflictReferenceSummary([ + ref(targetType: 'dives', name: 'Blue Hole'), + ref(targetType: 'tags', name: 'Wreck'), + ]), + 'Blue Hole${kConflictSummarySeparator}Wreck', + ); + }); + + test('skips references that resolved to no name', () { + expect( + conflictReferenceSummary([ + ref(targetType: 'dives', name: 'Blue Hole'), + ref(targetType: 'tags', exists: false), + ]), + 'Blue Hole', + ); + }); + + test('caps the summary so a wide row does not run away', () { + expect( + conflictReferenceSummary([ + for (final n in ['a', 'b', 'c', 'd', 'e']) ref(name: n), + ]), + 'a${kConflictSummarySeparator}b${kConflictSummarySeparator}c', + ); + }); + + test('is null when nothing resolved to a name', () { + expect(conflictReferenceSummary([ref(exists: false)]), isNull); + expect(conflictReferenceSummary(const []), isNull); + }); + }); + + group('short ids', () { + test('trims a uuid to its leading segment', () { + expect(shortRecordId('aabbccdd-1111-2222'), '#aabbccdd'); + }); + + test('leaves an id shorter than the trim alone', () { + expect(shortRecordId('ab12'), '#ab12'); + }); + }); + + group('entity names', () { + test('splits camelCase into words', () { + expect(humanizeEntityType('diveTags'), 'Dive Tags'); + expect(humanizeEntityType('qualityFindings'), 'Quality Findings'); + }); + + test('splits legacy snake_case too', () { + expect(humanizeEntityType('dive_sites'), 'Dive Sites'); + }); + + test('leaves a single lowercase word capitalized', () { + expect(humanizeEntityType('media'), 'Media'); + }); + }); +} diff --git a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart index cebf4a14ab..8059af1cf0 100644 --- a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart +++ b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart @@ -242,6 +242,97 @@ void main() { expect(find.textContaining('2700'), findsNothing); }); + testWidgets('shows nothing at all for a side with no data', (tester) async { + await pumpDialog( + tester, + SyncConflict( + entityType: 'diveTags', + recordId: 'dt-1', + localData: const {}, + remoteData: const {'id': 'dt-1', 'tagId': 't-1'}, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(find.text('No data available'), findsOneWidget); + }); + + testWidgets('falls back to raw columns when a finding cannot be read', ( + tester, + ) async { + // params is not valid JSON, so the localized sentence cannot be built. + // Hiding detectorId and params only makes sense when the sentence + // replaced them; without it the user would be left with nothing at all. + await pumpDialog( + tester, + SyncConflict( + entityType: 'qualityFindings', + recordId: 'qf-1', + localData: const { + 'id': 'qf-1', + 'detectorId': 'depth_spike', + 'detectorVersion': 1, + 'category': 'profile', + 'severity': 'warning', + 'status': 'open', + 'params': 'not json at all', + 'createdAt': 1786556582600, + }, + remoteData: const { + 'id': 'qf-1', + 'detectorId': 'depth_spike', + 'detectorVersion': 1, + 'category': 'profile', + 'severity': 'critical', + 'status': 'open', + 'params': 'not json at all', + 'createdAt': 1786556582600, + }, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(find.text('Finding:'), findsNothing); + expect(find.text('detectorId:'), findsNWidgets(2)); + expect(find.text('depth_spike'), findsNWidgets(2)); + }); + + testWidgets('falls back to raw columns for an unreadable finding category', ( + tester, + ) async { + // A category written by a newer schema is not a value this build knows. + await pumpDialog( + tester, + SyncConflict( + entityType: 'qualityFindings', + recordId: 'qf-2', + localData: const { + 'id': 'qf-2', + 'detectorId': 'depth_spike', + 'category': 'somethingNewer', + 'severity': 'warning', + 'status': 'open', + 'params': '{}', + }, + remoteData: const { + 'id': 'qf-2', + 'detectorId': 'depth_spike', + 'category': 'somethingNewer', + 'severity': 'critical', + 'status': 'open', + 'params': '{}', + }, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(find.text('Finding:'), findsNothing); + expect(find.text('detectorId:'), findsNWidgets(2)); + }); + testWidgets('renders a quality finding as its localized message', ( tester, ) async { From f562f97ed043cd7d444610469cbbe147b0203207 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 03:21:09 -0400 Subject: [PATCH 094/122] test: exclude the v163 Drift column declaration from coverage Codecov flagged the declaration's two lines as uncovered. They cannot be covered: a Drift column getter is a codegen input, shadowed at runtime by the generated table, so the body never executes. The comment a few lines below already says this, and defaultShowAscentRateLine and defaultShowPhotoMarkers are wrapped in coverage:ignore for the same reason. The column's default is pinned by migration_v163_estimated_tank_pressure_ default_test, which asserts dflt_value is 1 and that a 161 -> 163 upgrade leaves an existing row true, so nothing is lost by not counting the declaration. Local patch coverage goes from 84.38% to 87.10%. --- lib/core/database/database.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index f0e81a6f7f..71cf47890a 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1812,9 +1812,13 @@ class DiverSettings extends Table { boolean().withDefault(const Constant(false))(); // v163: whether synthesized ("(est.)") tank pressure lines are drawn on the // profile chart at all (issue #731). Defaults to true, preserving the - // behavior estimates shipped with. + // behavior estimates shipped with. Ignored for coverage for the reason + // given below: the declaration is a codegen input, never executed. Its + // default is pinned by migration_v163_estimated_tank_pressure_default_test. + // coverage:ignore-start BoolColumn get defaultShowEstimatedTankPressure => boolean().withDefault(const Constant(true))(); + // coverage:ignore-end // Drift column declarations are codegen inputs shadowed by the generated // table at runtime, so this line is never executed (every sibling column // getter is likewise uncovered). The default is verified via the migration From 9a99610531b083726c5be1e05a6fb087d81cae0e Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 03:38:57 -0400 Subject: [PATCH 095/122] docs: stop claiming v162 already landed on main The ladder entry and the migration test header both said v162 "was claimed first on main by #1090". That is wrong: PR #1287 is still open and origin/main reads currentSchemaVersion = 161, so v162 is claimed on a branch, not landed. A future schema audit reading either comment would conclude the gap at 162 came from a merged migration. Reworded both to say what actually happened: main was at v161 when this branch was cut, #1287 had already written 162 on its own branch, and two branches writing the same scalar auto-merge with no conflict marker, so 163 was taken instead. Verified before rewording that main is still 161 and #1287 is still open. --- lib/core/database/database.dart | 5 ++++- .../migration_v163_estimated_tank_pressure_default_test.dart | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 71cf47890a..6b4f84e74b 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -3461,7 +3461,10 @@ class AppDatabase extends _$AppDatabase { 161, // v163: diver_settings.default_show_estimated_tank_pressure, the switch // that suppresses synthesized "(est.)" tank pressure lines on the profile - // chart (issue #731). v162 was claimed first on main by #1090. + // chart (issue #731). v162 is skipped rather than missing: main was at + // v161 when this branch was cut, and the open PR #1287 (issue #1090) had + // already written 162 on its own branch. Two branches writing the same + // scalar auto-merge with no conflict marker, so 163 was taken instead. 163, ]; diff --git a/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart b/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart index aee312f54b..9e48ed85b5 100644 --- a/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart +++ b/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart @@ -25,8 +25,9 @@ NativeDatabase _dbAt161() { } /// v163 adds the switch that suppresses synthesized "(est.)" tank pressure -/// lines on the profile chart (issue #731). v162 was claimed first on main by -/// #1090. +/// lines on the profile chart (issue #731). v162 is skipped rather than +/// missing: main was at v161 when this branch was cut, and the open PR #1287 +/// (issue #1090) had already written 162 on its own branch. void main() { test('v163 is in the migration ladder', () { expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(163)); From f7fdce6f090bcc8647517b640beb65656ffea0f0 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 12:05:37 -0400 Subject: [PATCH 096/122] refactor(sync): build one unit formatter per conflict preview Review follow-up. ConflictDataPreview built a UnitFormatter from settingsProvider and then called buildQualityUnitFormatters, which watched the same provider and built a second one. Both happened on every build, for every entity type, even though only qualityFindings has any use for the finding formatters. Reaching that helper also meant importing the whole data_quality_inbox_page for one function. Extract the binding into quality_unit_formatters.dart, taking an already-built UnitFormatter rather than a WidgetRef so any caller with one can reuse it. The inbox page's ref-taking helper now delegates to it, and the preview builds the finding formatters inside the finding branch, after the detector guard, so a dive or site conflict does no formatter work at all. Also from the review: two test descriptions read "diver own" / "record own"; they now use a possessive. Two more tests, both pinning behavior that had none. A finding row missing a column entirely (schema drift from a peer) must degrade to raw columns rather than throw -- without the TypeError guard the widget dies with "type 'Null' is not a subtype of type 'String'" while building, taking the dialog with it. And a clock_offset finding renders its date through the diver's date format, which covers a second detector and shows the preview inherits the data-quality renderer's copy rather than special-casing depth spikes. Patch coverage stays at 100%. --- .../pages/data_quality_inbox_page.dart | 16 +--- .../widgets/quality_unit_formatters.dart | 20 +++++ .../widgets/conflict_data_preview.dart | 13 +--- .../conflict_reference_resolver_test.dart | 2 +- .../conflict_resolution_dialog_test.dart | 74 ++++++++++++++++++- .../widgets/conflict_scalar_format_test.dart | 2 +- 6 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 lib/features/data_quality/presentation/widgets/quality_unit_formatters.dart diff --git a/lib/features/data_quality/presentation/pages/data_quality_inbox_page.dart b/lib/features/data_quality/presentation/pages/data_quality_inbox_page.dart index fc2c75c645..12edb6c000 100644 --- a/lib/features/data_quality/presentation/pages/data_quality_inbox_page.dart +++ b/lib/features/data_quality/presentation/pages/data_quality_inbox_page.dart @@ -16,25 +16,15 @@ import 'package:submersion/features/data_quality/presentation/providers/data_qua import 'package:submersion/features/data_quality/presentation/providers/quality_inbox_providers.dart'; import 'package:submersion/features/data_quality/presentation/widgets/quality_finding_card.dart'; import 'package:submersion/features/data_quality/presentation/widgets/quality_finding_message.dart'; +import 'package:submersion/features/data_quality/presentation/widgets/quality_unit_formatters.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_log/presentation/widgets/combine_dives_dialog.dart'; import 'package:submersion/features/dive_log/presentation/widgets/run_dive_consolidation.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; -QualityUnitFormatters buildQualityUnitFormatters(WidgetRef ref) { - final units = UnitFormatter(ref.watch(settingsProvider)); - return QualityUnitFormatters( - depth: (m) => units.formatDepth(m), - pressure: (bar) => units.formatPressure(bar), - temperature: (c) => units.formatTemperature(c), - // Surface air consumption is a volume rate; honor the volume unit - // preference (L/min vs cuft/min) rather than the pressure-based SAC mode. - sac: (lpm) => - '${units.convertVolume(lpm).toStringAsFixed(1)} ${units.volumeSymbol}/min', - date: (d) => units.formatDate(d), - ); -} +QualityUnitFormatters buildQualityUnitFormatters(WidgetRef ref) => + qualityUnitFormattersFor(UnitFormatter(ref.watch(settingsProvider))); typedef _DiveGroup = ({String diveId, List findings}); diff --git a/lib/features/data_quality/presentation/widgets/quality_unit_formatters.dart b/lib/features/data_quality/presentation/widgets/quality_unit_formatters.dart new file mode 100644 index 0000000000..bf44e97bf9 --- /dev/null +++ b/lib/features/data_quality/presentation/widgets/quality_unit_formatters.dart @@ -0,0 +1,20 @@ +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/data_quality/presentation/widgets/quality_finding_message.dart'; + +/// Binds the finding renderer to a diver's units. +/// +/// Takes an already-built [UnitFormatter] rather than a `WidgetRef` so any +/// caller that has one can reuse it, instead of watching the settings provider +/// a second time just to reach these five closures. +QualityUnitFormatters qualityUnitFormattersFor( + UnitFormatter units, +) => QualityUnitFormatters( + depth: (m) => units.formatDepth(m), + pressure: (bar) => units.formatPressure(bar), + temperature: (c) => units.formatTemperature(c), + // Surface air consumption is a volume rate; honor the volume unit + // preference (L/min vs cuft/min) rather than the pressure-based SAC mode. + sac: (lpm) => + '${units.convertVolume(lpm).toStringAsFixed(1)} ${units.volumeSymbol}/min', + date: (d) => units.formatDate(d), +); diff --git a/lib/features/settings/presentation/widgets/conflict_data_preview.dart b/lib/features/settings/presentation/widgets/conflict_data_preview.dart index 1b5347f7cd..1932f4cdba 100644 --- a/lib/features/settings/presentation/widgets/conflict_data_preview.dart +++ b/lib/features/settings/presentation/widgets/conflict_data_preview.dart @@ -7,11 +7,8 @@ import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/sync/conflict_reference.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/data_quality/domain/entities/quality_finding.dart'; -// buildQualityUnitFormatters is the one place that binds the finding renderer -// to the diver's unit settings; it lives beside the inbox that first needed it. -import 'package:submersion/features/data_quality/presentation/pages/data_quality_inbox_page.dart' - show buildQualityUnitFormatters; import 'package:submersion/features/data_quality/presentation/widgets/quality_finding_message.dart'; +import 'package:submersion/features/data_quality/presentation/widgets/quality_unit_formatters.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/settings/presentation/widgets/conflict_reference_labels.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; @@ -80,7 +77,6 @@ class ConflictDataPreview extends ConsumerWidget { final rows = conflictPreviewRows( l10n: context.l10n, units: UnitFormatter(ref.watch(settingsProvider)), - findingFormatters: buildQualityUnitFormatters(ref), entityType: entityType, data: data, references: references, @@ -123,13 +119,12 @@ class ConflictDataPreview extends ConsumerWidget { List conflictPreviewRows({ required AppLocalizations l10n, required UnitFormatter units, - required QualityUnitFormatters findingFormatters, required String entityType, required Map data, required List references, }) { final message = entityType == 'qualityFindings' - ? _findingMessage(l10n, findingFormatters, data) + ? _findingMessage(l10n, units, data) : null; final hidden = { @@ -275,7 +270,7 @@ Map _remainingScalars( /// raw columns, which is what it did before. QualityFindingMessage? _findingMessage( AppLocalizations l10n, - QualityUnitFormatters formatters, + UnitFormatter units, Map data, ) { final detectorId = data['detectorId']; @@ -298,7 +293,7 @@ QualityFindingMessage? _findingMessage( (data['updatedAt'] as num?)?.toInt() ?? 0, ), ); - return buildFindingMessage(l10n, finding, formatters); + return buildFindingMessage(l10n, finding, qualityUnitFormattersFor(units)); } on ArgumentError catch (e) { _log.warning('Conflict preview could not read a finding row', error: e); return null; diff --git a/test/core/services/sync/conflict_reference_resolver_test.dart b/test/core/services/sync/conflict_reference_resolver_test.dart index 8dbea4929b..fff92a6d31 100644 --- a/test/core/services/sync/conflict_reference_resolver_test.dart +++ b/test/core/services/sync/conflict_reference_resolver_test.dart @@ -110,7 +110,7 @@ void main() { expect(dive.isMissing, isFalse); }); - test('ignores the record own id and non-reference fields', () async { + test("ignores the record's own id and non-reference fields", () async { await seedDive('dive-1'); final refs = await resolver.resolve('qualityFindings', { diff --git a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart index 8059af1cf0..edf94586a0 100644 --- a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart +++ b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart @@ -189,7 +189,7 @@ void main() { expect(find.byIcon(Icons.place), findsOneWidget); }); - testWidgets('renders a depth in the diver configured unit', (tester) async { + testWidgets("renders a depth in the diver's configured unit", (tester) async { await pumpDialog( tester, SyncConflict( @@ -333,6 +333,78 @@ void main() { expect(find.text('detectorId:'), findsNWidgets(2)); }); + testWidgets('renders a detector that dates its finding', (tester) async { + // A second detector, to show the preview inherits every detector's copy + // from the data-quality renderer rather than special-casing depth spikes. + // clock_offset formats its stored epoch through the diver's date format. + await pumpDialog( + tester, + SyncConflict( + entityType: 'qualityFindings', + recordId: 'qf-clock', + localData: const { + 'id': 'qf-clock', + 'detectorId': 'clock_offset', + 'detectorVersion': 1, + 'category': 'time', + 'severity': 'warning', + 'status': 'open', + 'params': '{"entryTimeMs":-2208988800000}', + }, + remoteData: const { + 'id': 'qf-clock', + 'detectorId': 'clock_offset', + 'detectorVersion': 1, + 'category': 'time', + 'severity': 'critical', + 'status': 'open', + 'params': '{"entryTimeMs":-2208988800000}', + }, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(find.textContaining('Clock & timezone'), findsNWidgets(2)); + expect(find.textContaining('dated before 1950'), findsNWidgets(2)); + expect(find.textContaining('1900'), findsNWidgets(2)); + }); + + testWidgets('falls back to raw columns for a finding missing a column', ( + tester, + ) async { + // A row that reached this device without a category at all. Without the + // guard the cast throws and takes the whole dialog down with it, leaving + // the conflict unresolvable. + await pumpDialog( + tester, + SyncConflict( + entityType: 'qualityFindings', + recordId: 'qf-3', + localData: const { + 'id': 'qf-3', + 'detectorId': 'depth_spike', + 'severity': 'warning', + 'status': 'open', + 'params': '{}', + }, + remoteData: const { + 'id': 'qf-3', + 'detectorId': 'depth_spike', + 'severity': 'critical', + 'status': 'open', + 'params': '{}', + }, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Finding:'), findsNothing); + expect(find.text('detectorId:'), findsNWidgets(2)); + }); + testWidgets('renders a quality finding as its localized message', ( tester, ) async { diff --git a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart index 473e209496..ee8464066f 100644 --- a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart +++ b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart @@ -56,7 +56,7 @@ void main() { }); }); - test('converts to the diver own units rather than the stored ones', () { + test("converts to the diver's own units rather than the stored ones", () { const imperial = UnitFormatter( AppSettings( depthUnit: DepthUnit.feet, From b1033ae82dae935612d11c970e9a96b24b720b3a Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Tue, 25 Aug 2026 21:48:31 +0200 Subject: [PATCH 097/122] feat: make the master-detail pane divider user-resizable Adds a drag handle to the divider between the list and detail panes so users can widen or narrow the list pane, shared across every section (dives, sites, gear, etc.) for the session. Not persisted to disk yet. Closes #1268 --- .../master_detail/master_detail_scaffold.dart | 141 +++++++++---- .../master_detail_scaffold_resize_test.dart | 188 ++++++++++++++++++ 2 files changed, 289 insertions(+), 40 deletions(-) create mode 100644 test/shared/widgets/master_detail/master_detail_scaffold_resize_test.dart diff --git a/lib/shared/widgets/master_detail/master_detail_scaffold.dart b/lib/shared/widgets/master_detail/master_detail_scaffold.dart index 6a9e2b59d7..183d5d1403 100644 --- a/lib/shared/widgets/master_detail/master_detail_scaffold.dart +++ b/lib/shared/widgets/master_detail/master_detail_scaffold.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; import 'package:go_router/go_router.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -13,6 +14,24 @@ import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.d /// one) has to match it or the same card appears at two different widths. const double kMasterPaneWidth = 440; +/// Minimum width the master pane can be resized to. +const double _kMasterPaneMinWidth = 280; + +/// Maximum width the master pane can be resized to. +const double _kMasterPaneMaxWidth = 700; + +/// Width of the space reserved for the detail pane, subtracted from the +/// available width when computing the resize maximum so the detail pane +/// always keeps a usable minimum width. +const double _kDetailPaneReservedWidth = 400; + +/// Master pane width, user-resizable via the divider and shared across every +/// [MasterDetailScaffold] instance for the lifetime of the app session (not +/// persisted to disk, so it resets on restart). +final masterPaneWidthProvider = StateProvider( + (ref) => kMasterPaneWidth, +); + /// Mode for the detail pane in master-detail layout. enum DetailPaneMode { /// Viewing item details (default) @@ -310,49 +329,68 @@ class _MasterDetailScaffoldState extends ConsumerState { ); } - // Desktop: Split view with fixed-width master pane + // Desktop: Split view with a user-resizable master pane return Scaffold( - body: Row( - children: [ - // Master pane (list) with fixed width - SizedBox( - width: widget.masterWidth, - child: ExcludeFocusTraversal( - excluding: isEditingDetail, - child: _MasterPane( - floatingActionButton: widget.floatingActionButton != null - ? _wrapFabForCreate(widget.floatingActionButton!) - : null, - child: widget.masterBuilder( - context, - _onItemSelected, - selectedId, + body: LayoutBuilder( + builder: (context, constraints) { + final maxWidth = (constraints.maxWidth - _kDetailPaneReservedWidth) + .clamp(_kMasterPaneMinWidth, _kMasterPaneMaxWidth); + final width = ref + .watch(masterPaneWidthProvider) + .clamp(_kMasterPaneMinWidth, maxWidth); + + return Row( + children: [ + // Master pane (list), user-resizable + SizedBox( + key: const Key('master-detail-master-pane'), + width: width, + child: ExcludeFocusTraversal( + excluding: isEditingDetail, + child: _MasterPane( + floatingActionButton: widget.floatingActionButton != null + ? _wrapFabForCreate(widget.floatingActionButton!) + : null, + child: widget.masterBuilder( + context, + _onItemSelected, + selectedId, + ), + ), ), ), - ), - ), - // Vertical divider - const VerticalDivider(width: 1, thickness: 1), - // Detail pane (or map view) - Expanded( - child: widget.mapBuilder != null && _isMapView - ? widget.mapBuilder!(context, selectedId, _onItemSelected) - : _DetailPane( - selectedId: selectedId, - mode: mode, - detailBuilder: widget.detailBuilder, - summaryBuilder: widget.summaryBuilder, - editBuilder: widget.editBuilder, - createBuilder: widget.createBuilder, - onClose: () => _onItemSelected(null), - onSaved: _onSaved, - onCancel: _onCancel, - detailScrollOffset: _detailScrollOffset, - onDetailScrollOffsetChanged: (offset) => - _detailScrollOffset = offset, - ), - ), - ], + // Draggable divider + _ResizeHandle( + onDrag: (delta) { + final notifier = ref.read(masterPaneWidthProvider.notifier); + notifier.state = (notifier.state + delta).clamp( + _kMasterPaneMinWidth, + maxWidth, + ); + }, + ), + // Detail pane (or map view) + Expanded( + child: widget.mapBuilder != null && _isMapView + ? widget.mapBuilder!(context, selectedId, _onItemSelected) + : _DetailPane( + selectedId: selectedId, + mode: mode, + detailBuilder: widget.detailBuilder, + summaryBuilder: widget.summaryBuilder, + editBuilder: widget.editBuilder, + createBuilder: widget.createBuilder, + onClose: () => _onItemSelected(null), + onSaved: _onSaved, + onCancel: _onCancel, + detailScrollOffset: _detailScrollOffset, + onDetailScrollOffsetChanged: (offset) => + _detailScrollOffset = offset, + ), + ), + ], + ); + }, ), ); } @@ -383,6 +421,29 @@ class _MasterDetailScaffoldState extends ConsumerState { } } +/// Draggable divider between the master and detail panes. +class _ResizeHandle extends StatelessWidget { + final ValueChanged onDrag; + + const _ResizeHandle({required this.onDrag}); + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + key: const Key('master-detail-resize-handle'), + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (details) => onDrag(details.delta.dx), + child: const SizedBox( + width: 8, + child: Center(child: VerticalDivider(width: 1, thickness: 1)), + ), + ), + ); + } +} + /// Container for the master (list) pane with optional FAB. class _MasterPane extends StatelessWidget { final Widget child; diff --git a/test/shared/widgets/master_detail/master_detail_scaffold_resize_test.dart b/test/shared/widgets/master_detail/master_detail_scaffold_resize_test.dart new file mode 100644 index 0000000000..8204f02c6e --- /dev/null +++ b/test/shared/widgets/master_detail/master_detail_scaffold_resize_test.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/shared/widgets/master_detail/master_detail_scaffold.dart'; + +/// Builds a [MasterDetailScaffold] at desktop width with a single item. +Widget _app() { + final router = GoRouter( + initialLocation: '/test', + routes: [ + GoRoute( + path: '/test', + builder: (context, state) => MasterDetailScaffold( + sectionId: 'test', + masterBuilder: (context, onSelect, selectedId) => + const Text('Master'), + detailBuilder: (_, id) => Text('Detail $id'), + summaryBuilder: (_) => const Text('Summary'), + ), + ), + ], + ); + + return ProviderScope( + child: MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); +} + +/// Builds an app with two routes, each a [MasterDetailScaffold] for a +/// different section, sharing one [GoRouter] so navigation between them can +/// be driven from the test. +(Widget, GoRouter) _twoSectionApp() { + final router = GoRouter( + initialLocation: '/a', + routes: [ + GoRoute( + path: '/a', + builder: (context, state) => MasterDetailScaffold( + sectionId: 'a', + masterBuilder: (context, onSelect, selectedId) => + const Text('Master A'), + detailBuilder: (_, id) => Text('Detail $id'), + summaryBuilder: (_) => const Text('Summary A'), + ), + ), + GoRoute( + path: '/b', + builder: (context, state) => MasterDetailScaffold( + sectionId: 'b', + masterBuilder: (context, onSelect, selectedId) => + const Text('Master B'), + detailBuilder: (_, id) => Text('Detail $id'), + summaryBuilder: (_) => const Text('Summary B'), + ), + ), + ], + ); + + final app = ProviderScope( + child: MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + return (app, router); +} + +double _masterPaneWidth(WidgetTester tester) { + return tester + .widget(find.byKey(const Key('master-detail-master-pane'))) + .width!; +} + +/// Sets a real 1200x800 desktop viewport (not just a MediaQuery override) so +/// the master pane's LayoutBuilder sees the width this test expects. +void _setDesktopViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +void main() { + group('MasterDetailScaffold resize', () { + testWidgets('dragging the divider right widens the master pane', ( + tester, + ) async { + _setDesktopViewport(tester); + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + final initialWidth = _masterPaneWidth(tester); + + await tester.drag( + find.byKey(const Key('master-detail-resize-handle')), + const Offset(80, 0), + ); + await tester.pump(); + + // tester.drag() can split the movement into several update events with + // touch-slop compensation that isn't pixel-exact, so allow a small + // tolerance rather than asserting the delta precisely. + expect(_masterPaneWidth(tester), closeTo(initialWidth + 80, 5)); + }); + + testWidgets('dragging the divider left narrows the master pane', ( + tester, + ) async { + _setDesktopViewport(tester); + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + final initialWidth = _masterPaneWidth(tester); + + await tester.drag( + find.byKey(const Key('master-detail-resize-handle')), + const Offset(-80, 0), + ); + await tester.pump(); + + expect(_masterPaneWidth(tester), closeTo(initialWidth - 80, 5)); + }); + + testWidgets('drag past the minimum clamps the master pane width', ( + tester, + ) async { + _setDesktopViewport(tester); + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('master-detail-resize-handle')), + const Offset(-1000, 0), + ); + await tester.pump(); + + expect(_masterPaneWidth(tester), 280); + }); + + testWidgets('drag past the maximum clamps the master pane width', ( + tester, + ) async { + _setDesktopViewport(tester); + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('master-detail-resize-handle')), + const Offset(1000, 0), + ); + await tester.pump(); + + // Window is 1200 wide; max is min(700, 1200 - 400) = 700. + expect(_masterPaneWidth(tester), 700); + }); + + testWidgets( + 'resized width carries over when navigating to another section', + (tester) async { + _setDesktopViewport(tester); + final (app, router) = _twoSectionApp(); + await tester.pumpWidget(app); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('master-detail-resize-handle')), + const Offset(80, 0), + ); + await tester.pump(); + final resizedWidth = _masterPaneWidth(tester); + expect(resizedWidth, isNot(kMasterPaneWidth)); + + router.go('/b'); + await tester.pumpAndSettle(); + + expect(find.text('Summary B'), findsOneWidget); + expect(_masterPaneWidth(tester), resizedWidth); + }, + ); + }); +} From b694ad9d6a6b39a3295c2b9cdcf70a9283d23b1e Mon Sep 17 00:00:00 2001 From: Cornelius Schmale Date: Wed, 26 Aug 2026 18:17:39 +0200 Subject: [PATCH 098/122] fix: account for handle width in resize clamp, add divider a11y label The master pane resize maximum reserved space for the detail pane but not for the 8px resize handle itself, letting the detail pane shrink below its intended minimum. Also add a Semantics label to the resize handle so screen readers announce it as a resizable splitter. --- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 4 +++ lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 ++++ lib/l10n/arb/app_localizations_ar.dart | 4 +++ lib/l10n/arb/app_localizations_de.dart | 4 +++ lib/l10n/arb/app_localizations_en.dart | 3 ++ lib/l10n/arb/app_localizations_es.dart | 4 +++ lib/l10n/arb/app_localizations_fr.dart | 4 +++ lib/l10n/arb/app_localizations_he.dart | 4 +++ lib/l10n/arb/app_localizations_hu.dart | 3 ++ lib/l10n/arb/app_localizations_it.dart | 4 +++ lib/l10n/arb/app_localizations_nl.dart | 4 +++ lib/l10n/arb/app_localizations_pt.dart | 4 +++ lib/l10n/arb/app_localizations_zh.dart | 3 ++ lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + .../master_detail/master_detail_scaffold.dart | 31 ++++++++++++------- 24 files changed, 81 insertions(+), 11 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 10b16f4ea2..d26d90877d 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "لوحة قائمة {title}", "accessibility_label_mapPane": "لوحة خريطة {title}", "accessibility_label_mapViewTitle": "عرض خريطة {title}", + "accessibility_label_resizeMasterPane": "تغيير حجم اللوحة الرئيسية", "accessibility_label_showList": "عرض القائمة", "accessibility_label_showMapView": "عرض الخريطة", "accessibility_label_viewDetails": "عرض التفاصيل", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index ba51ebd7ff..714617e9da 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "{title} Listenbereich", "accessibility_label_mapPane": "{title} Kartenbereich", "accessibility_label_mapViewTitle": "{title} Kartenansicht", + "accessibility_label_resizeMasterPane": "Hauptbereich in der Groesse aendern", "accessibility_label_showList": "Liste anzeigen", "accessibility_label_showMapView": "Kartenansicht anzeigen", "accessibility_label_viewDetails": "Details anzeigen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 6610752de3..4a26fd291e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -472,6 +472,10 @@ "accessibility_label_listPane": "{title} list pane", "accessibility_label_mapPane": "{title} map pane", "accessibility_label_mapViewTitle": "{title} map view", + "accessibility_label_resizeMasterPane": "Resize master pane", + "@accessibility_label_resizeMasterPane": { + "description": "Semantics label for the draggable divider between the master and detail panes in master-detail scaffold" + }, "accessibility_label_sharedWithAllProfiles": "Shared with all dive profiles", "@accessibility_label_sharedWithAllProfiles": { "description": "Screen-reader / tooltip label for the people icon shown on trip and site list tiles when a record is shared across dive profiles. Descriptive form (state), distinct from the imperative form used on the edit-page switch." diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index a19fcd2abc..717e4d2aee 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "Panel de lista de {title}", "accessibility_label_mapPane": "Panel de mapa de {title}", "accessibility_label_mapViewTitle": "Vista de mapa de {title}", + "accessibility_label_resizeMasterPane": "Cambiar el tamano del panel principal", "accessibility_label_showList": "Mostrar lista", "accessibility_label_showMapView": "Mostrar vista de mapa", "accessibility_label_viewDetails": "Ver detalles", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 49f7a05942..3b05049d74 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "Volet liste {title}", "accessibility_label_mapPane": "Volet carte {title}", "accessibility_label_mapViewTitle": "Vue carte {title}", + "accessibility_label_resizeMasterPane": "Redimensionner le panneau principal", "accessibility_label_showList": "Afficher la liste", "accessibility_label_showMapView": "Afficher la vue carte", "accessibility_label_viewDetails": "Voir les details", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 3d52990666..560bc024e2 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "חלונית רשימת {title}", "accessibility_label_mapPane": "חלונית מפת {title}", "accessibility_label_mapViewTitle": "תצוגת מפה של {title}", + "accessibility_label_resizeMasterPane": "שינוי גודל החלונית הראשית", "accessibility_label_showList": "הצגת רשימה", "accessibility_label_showMapView": "הצגת תצוגת מפה", "accessibility_label_viewDetails": "הצגת פרטים", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 9a3fed9d93..03bcc040ce 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "{title} lista panel", "accessibility_label_mapPane": "{title} terkep panel", "accessibility_label_mapViewTitle": "{title} terkepi nezet", + "accessibility_label_resizeMasterPane": "Fo panel atmeretezese", "accessibility_label_showList": "Lista megjelenitese", "accessibility_label_showMapView": "Terkepi nezet megjelenitese", "accessibility_label_viewDetails": "Reszletek megtekintese", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 1e8818c887..e5382a1fa1 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "Pannello elenco {title}", "accessibility_label_mapPane": "Pannello mappa {title}", "accessibility_label_mapViewTitle": "Vista mappa {title}", + "accessibility_label_resizeMasterPane": "Ridimensiona riquadro principale", "accessibility_label_showList": "Mostra elenco", "accessibility_label_showMapView": "Mostra vista mappa", "accessibility_label_viewDetails": "Visualizza dettagli", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 9907057224..783a5dd4bd 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -1357,6 +1357,12 @@ abstract class AppLocalizations { /// **'{title} map view'** String accessibility_label_mapViewTitle(Object title); + /// Semantics label for the draggable divider between the master and detail panes in master-detail scaffold + /// + /// In en, this message translates to: + /// **'Resize master pane'** + String get accessibility_label_resizeMasterPane; + /// Screen-reader / tooltip label for the people icon shown on trip and site list tiles when a record is shared across dive profiles. Descriptive form (state), distinct from the imperative form used on the edit-page switch. /// /// 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 067ccc0831..8cb7d9c087 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -791,6 +791,10 @@ class AppLocalizationsAr extends AppLocalizations { return 'عرض خريطة $title'; } + @override + String get accessibility_label_resizeMasterPane => + 'تغيير حجم اللوحة الرئيسية'; + @override String get accessibility_label_sharedWithAllProfiles => 'مشترك مع جميع ملفات الغوص'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index ca27df842c..0785b7397e 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -798,6 +798,10 @@ class AppLocalizationsDe extends AppLocalizations { return '$title Kartenansicht'; } + @override + String get accessibility_label_resizeMasterPane => + 'Hauptbereich in der Groesse aendern'; + @override String get accessibility_label_sharedWithAllProfiles => 'Mit allen Taucherprofilen geteilt'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index bad80c6f7e..0f66b01be8 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -787,6 +787,9 @@ class AppLocalizationsEn extends AppLocalizations { return '$title map view'; } + @override + String get accessibility_label_resizeMasterPane => 'Resize master pane'; + @override String get accessibility_label_sharedWithAllProfiles => 'Shared with all dive profiles'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index c3fca1f886..12120ed4d2 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -800,6 +800,10 @@ class AppLocalizationsEs extends AppLocalizations { return 'Vista de mapa de $title'; } + @override + String get accessibility_label_resizeMasterPane => + 'Cambiar el tamano del panel principal'; + @override String get accessibility_label_sharedWithAllProfiles => 'Compartido con todos los perfiles de buceo'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e7bf4922ee..237cc20dab 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -803,6 +803,10 @@ class AppLocalizationsFr extends AppLocalizations { return 'Vue carte $title'; } + @override + String get accessibility_label_resizeMasterPane => + 'Redimensionner le panneau principal'; + @override String get accessibility_label_sharedWithAllProfiles => 'Partagé avec tous les profils de plongée'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 71113836d8..0df5963421 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -781,6 +781,10 @@ class AppLocalizationsHe extends AppLocalizations { return 'תצוגת מפה של $title'; } + @override + String get accessibility_label_resizeMasterPane => + 'שינוי גודל החלונית הראשית'; + @override String get accessibility_label_sharedWithAllProfiles => 'משותף עם כל פרופילי הצלילה'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 0ad2ff2219..bae69c1f3c 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -796,6 +796,9 @@ class AppLocalizationsHu extends AppLocalizations { return '$title terkepi nezet'; } + @override + String get accessibility_label_resizeMasterPane => 'Fo panel atmeretezese'; + @override String get accessibility_label_sharedWithAllProfiles => 'Megosztva az összes búvárprofillal'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 0c3623e6ac..20ed3d4773 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -799,6 +799,10 @@ class AppLocalizationsIt extends AppLocalizations { return 'Vista mappa $title'; } + @override + String get accessibility_label_resizeMasterPane => + 'Ridimensiona riquadro principale'; + @override String get accessibility_label_sharedWithAllProfiles => 'Condiviso con tutti i profili subacquei'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 0fa00e1e5d..3a7e5812e8 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -796,6 +796,10 @@ class AppLocalizationsNl extends AppLocalizations { return '$title kaartweergave'; } + @override + String get accessibility_label_resizeMasterPane => + 'Hoofdvenster formaat aanpassen'; + @override String get accessibility_label_sharedWithAllProfiles => 'Gedeeld met alle duikersprofielen'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 341c430f64..122bd8a617 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -797,6 +797,10 @@ class AppLocalizationsPt extends AppLocalizations { return 'Visualizacao do mapa $title'; } + @override + String get accessibility_label_resizeMasterPane => + 'Redimensionar painel principal'; + @override String get accessibility_label_sharedWithAllProfiles => 'Partilhado com todos os perfis de mergulho'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index d6f3317944..e04776ccc9 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -743,6 +743,9 @@ class AppLocalizationsZh extends AppLocalizations { return '$title地图视图'; } + @override + String get accessibility_label_resizeMasterPane => '调整主窗格大小'; + @override String get accessibility_label_sharedWithAllProfiles => '已与所有潜水员资料共享'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 8c4eccfa6a..1723cac6aa 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "{title} lijstpaneel", "accessibility_label_mapPane": "{title} kaartpaneel", "accessibility_label_mapViewTitle": "{title} kaartweergave", + "accessibility_label_resizeMasterPane": "Hoofdvenster formaat aanpassen", "accessibility_label_showList": "Lijst tonen", "accessibility_label_showMapView": "Kaartweergave tonen", "accessibility_label_viewDetails": "Details bekijken", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index e24cf6b3f6..c1b8a7da63 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -429,6 +429,7 @@ "accessibility_label_listPane": "Painel de lista {title}", "accessibility_label_mapPane": "Painel do mapa {title}", "accessibility_label_mapViewTitle": "Visualizacao do mapa {title}", + "accessibility_label_resizeMasterPane": "Redimensionar painel principal", "accessibility_label_showList": "Mostrar Lista", "accessibility_label_showMapView": "Mostrar Visualizacao do Mapa", "accessibility_label_viewDetails": "Ver detalhes", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 642058447d..133c4bad05 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -523,6 +523,7 @@ "accessibility_label_listPane": "{title}列表面板", "accessibility_label_mapPane": "{title}地图面板", "accessibility_label_mapViewTitle": "{title}地图视图", + "accessibility_label_resizeMasterPane": "调整主窗格大小", "accessibility_label_showList": "显示列表", "accessibility_label_showMapView": "显示地图视图", "accessibility_label_viewDetails": "查看详情", diff --git a/lib/shared/widgets/master_detail/master_detail_scaffold.dart b/lib/shared/widgets/master_detail/master_detail_scaffold.dart index 183d5d1403..9f8aa0f746 100644 --- a/lib/shared/widgets/master_detail/master_detail_scaffold.dart +++ b/lib/shared/widgets/master_detail/master_detail_scaffold.dart @@ -25,6 +25,9 @@ const double _kMasterPaneMaxWidth = 700; /// always keeps a usable minimum width. const double _kDetailPaneReservedWidth = 400; +/// Width of the draggable divider between the master and detail panes. +const double _kResizeHandleWidth = 8; + /// Master pane width, user-resizable via the divider and shared across every /// [MasterDetailScaffold] instance for the lifetime of the app session (not /// persisted to disk, so it resets on restart). @@ -333,8 +336,11 @@ class _MasterDetailScaffoldState extends ConsumerState { return Scaffold( body: LayoutBuilder( builder: (context, constraints) { - final maxWidth = (constraints.maxWidth - _kDetailPaneReservedWidth) - .clamp(_kMasterPaneMinWidth, _kMasterPaneMaxWidth); + final maxWidth = + (constraints.maxWidth - + _kDetailPaneReservedWidth - + _kResizeHandleWidth) + .clamp(_kMasterPaneMinWidth, _kMasterPaneMaxWidth); final width = ref .watch(masterPaneWidthProvider) .clamp(_kMasterPaneMinWidth, maxWidth); @@ -429,15 +435,18 @@ class _ResizeHandle extends StatelessWidget { @override Widget build(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - child: GestureDetector( - key: const Key('master-detail-resize-handle'), - behavior: HitTestBehavior.opaque, - onHorizontalDragUpdate: (details) => onDrag(details.delta.dx), - child: const SizedBox( - width: 8, - child: Center(child: VerticalDivider(width: 1, thickness: 1)), + return Semantics( + label: context.l10n.accessibility_label_resizeMasterPane, + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + key: const Key('master-detail-resize-handle'), + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (details) => onDrag(details.delta.dx), + child: const SizedBox( + width: _kResizeHandleWidth, + child: Center(child: VerticalDivider(width: 1, thickness: 1)), + ), ), ), ); From 2d33b711bc0b75d9c5bf06ee4f6673cee47c3508 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 12:51:32 -0400 Subject: [PATCH 099/122] fix(sac): convert segments with the cylinder that fed them, never a stray volume Review follow-up on #1298. The SAC-by-segment card converted with the first tank that had any volume, so a stage bottle's size could convert back-gas segments and, with that stand-in present, the missing-volume hint was suppressed. A segment now uses its own cylinder when attributed, otherwise the same reference (back gas) cylinder the pressure lane reads, extracted as Dive.sacReferenceTank; a segment whose cylinder has no size stays in pressure units and the hint appears. Also lifts the download-side preset loader out of the provider closure (loadDefaultTankPresetForDownloads) so it can be tested against the in-memory database, and covers tapping the hint into the dive editor. --- .../providers/download_providers.dart | 23 +-- .../dive_log/domain/entities/dive.dart | 25 ++-- .../presentation/pages/dive_detail_page.dart | 48 +++---- .../download_default_tank_preset_test.dart | 77 ++++++++++ .../domain/entities/dive_sac_fix_test.dart | 31 ++++ .../pages/dive_detail_sac_row_test.dart | 54 +++++++ .../dive_detail_sac_segments_hint_test.dart | 132 ++++++++++++++---- 7 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart diff --git a/lib/features/dive_computer/presentation/providers/download_providers.dart b/lib/features/dive_computer/presentation/providers/download_providers.dart index ab31f8bd51..5ed916c089 100644 --- a/lib/features/dive_computer/presentation/providers/download_providers.dart +++ b/lib/features/dive_computer/presentation/providers/download_providers.dart @@ -18,6 +18,7 @@ import 'package:submersion/features/dive_computer/presentation/providers/discove 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'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; import 'package:submersion/features/tank_presets/domain/services/default_tank_preset_resolver.dart'; import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; @@ -36,17 +37,23 @@ final diveImportServiceProvider = Provider((ref) { gpsTrackMatchService: ref.watch(gpsTrackMatchServiceProvider), // Read at import time, not provider build time, so a toggle flipped in // Settings applies to the very next download (issue #386). - defaultTankPresetForImports: () async { - final settings = ref.read(settingsProvider); - if (!settings.applyDefaultTankToImports) return null; - final resolver = DefaultTankPresetResolver( - repository: ref.read(tankPresetRepositoryProvider), - ); - return resolver.resolve(settings.defaultTankPreset); - }, + defaultTankPresetForImports: () => loadDefaultTankPresetForDownloads(ref), ); }); +/// The default tank preset to fill downloaded cylinders with, or null when +/// the diver has not opted in ("Also apply to imported dives" off) or the +/// configured preset no longer exists. +@visibleForTesting +Future loadDefaultTankPresetForDownloads(Ref ref) async { + final settings = ref.read(settingsProvider); + if (!settings.applyDefaultTankToImports) return null; + final resolver = DefaultTankPresetResolver( + repository: ref.read(tankPresetRepositoryProvider), + ); + return resolver.resolve(settings.defaultTankPreset); +} + /// Stream provider for download events from the service. final downloadEventsProvider = StreamProvider((ref) { final service = ref.watch(diveComputerServiceProvider); diff --git a/lib/features/dive_log/domain/entities/dive.dart b/lib/features/dive_log/domain/entities/dive.dart index ef844b7957..bc6cebc53c 100644 --- a/lib/features/dive_log/domain/entities/dive.dart +++ b/lib/features/dive_log/domain/entities/dive.dart @@ -415,6 +415,19 @@ class Dive extends Equatable { return totalGasLiters / minutes / avgPressureBar; } + /// The cylinder the pressure lane ([sacPressure]) reads, and the one whose + /// volume converts an unattributed SAC segment to L/min: on a multi-tank + /// dive the back gas, else the first cylinder; the only cylinder on a + /// single-tank dive whatever its role. Null when the dive has no cylinders. + DiveTank? get sacReferenceTank { + if (tanks.isEmpty) return null; + if (tanks.length == 1) return tanks.first; + return tanks.firstWhere( + (t) => t.role == TankRole.backGas, + orElse: () => tanks.first, + ); + } + /// Air consumption rate in pressure units per minute (bar/min or psi/min) /// This is a simpler calculation that doesn't require tank volume. /// It calculates the average pressure drop per minute adjusted for depth. @@ -428,17 +441,7 @@ class Dive extends Equatable { final avgPressureAtm = (avgDepth! / 10) + 1; // Convert depth to ATM - // For multi-tank dives use back gas only; single-tank dives use that tank. - // If no tank has TankRole.backGas, fall back to the first tank. - final DiveTank referenceTank; - if (tanks.length == 1) { - referenceTank = tanks.first; - } else { - referenceTank = tanks.firstWhere( - (t) => t.role == TankRole.backGas, - orElse: () => tanks.first, - ); - } + final referenceTank = sacReferenceTank!; if (referenceTank.startPressure == null || referenceTank.endPressure == null) { diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index b33dc3804d..5fe333c499 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -2274,40 +2274,33 @@ class _DiveDetailPageState extends ConsumerState { ? analysis.sacSegments! : segments; - // Get tank volume for L/min conversion (use first tank with volume) - final tankVolume = dive.tanks - .where((t) => t.volume != null && t.volume! > 0) - .map((t) => t.volume!) - .firstOrNull; + // The volume that converts a segment to L/min. An attributed segment + // uses its own cylinder (sidemount tanks differ in size, so one shared + // volume misconverts half the segments, #110); an unattributed one uses + // the same reference cylinder the pressure lane reads. Never another + // bottle that merely happens to have a size: a stage's volume says + // nothing about the back gas the segment describes. Null means the + // segment stays in pressure units. + double? volumeForSegment(String? segmentTankId) { + final tank = segmentTankId == null + ? dive.sacReferenceTank + : dive.tanks.where((t) => t.id == segmentTankId).firstOrNull; + final volume = tank?.volume; + return volume != null && volume > 0 ? volume : null; + } // Use the top-level normalization function final normalizationFactor = calculateSacNormalizationFactor(dive, analysis); // Format SAC value based on unit setting, applying normalization. - // [segmentTankId] selects that segment's own cylinder volume for the - // L/min conversion -- sidemount tanks can differ in size, so one shared - // volume misconverts half the segments (#110). Falls back to the first - // tank with a volume when the segment carries no attribution. String formatSacValue(double sacBarPerMin, {String? segmentTankId}) { // Apply normalization to align with overall dive SAC final normalizedSac = sacBarPerMin * normalizationFactor; + final volume = volumeForSegment(segmentTankId); - final segmentVolume = segmentTankId == null - ? null - : dive.tanks - .where( - (t) => - t.id == segmentTankId && - t.volume != null && - t.volume! > 0, - ) - .map((t) => t.volume!) - .firstOrNull; - final effectiveVolume = segmentVolume ?? tankVolume; - - if (sacUnit == SacUnit.litersPerMin && effectiveVolume != null) { + if (sacUnit == SacUnit.litersPerMin && volume != null) { // Convert bar/min to L/min: sacLPerMin = sacBarPerMin * tankVolume - final sacLPerMin = normalizedSac * effectiveVolume; + final sacLPerMin = normalizedSac * volume; return '${units.convertVolume(sacLPerMin).toStringAsFixed(1)} ${units.volumeSymbol}/min'; } else { // Convert to user's pressure unit (bar or psi) @@ -2480,8 +2473,11 @@ class _DiveDetailPageState extends ConsumerState { ), ); }), - // Segments fell back to the pressure lane above: say why. - if (sacUnit == SacUnit.litersPerMin && tankVolume == null) ...[ + // Some segment fell back to the pressure lane above: say why. + if (sacUnit == SacUnit.litersPerMin && + renderSegments.any( + (s) => volumeForSegment(s.tankId) == null, + )) ...[ const SizedBox(height: 8), SacVolumeHint( volumeSymbol: units.volumeSymbol, diff --git a/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart b/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart new file mode 100644 index 0000000000..4191d3a17f --- /dev/null +++ b/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_computer/presentation/providers/download_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +/// The loader `diveImportServiceProvider` hands to `DiveImportService` +/// (issue #386): it turns the diver's settings into the preset to fill +/// downloaded cylinders with, or null when the diver has not opted in. +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future load(AppSettings settings) async { + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + settingsProvider.overrideWith((ref) => MockSettingsNotifier(settings)), + ], + ); + addTearDown(container.dispose); + final probe = FutureProvider( + (ref) => loadDefaultTankPresetForDownloads(ref), + ); + return container.read(probe.future); + } + + test('yields nothing while the toggle is off', () async { + final preset = await load( + const AppSettings( + applyDefaultTankToImports: false, + defaultTankPreset: 'al80', + ), + ); + + expect(preset, isNull); + }); + + test( + 'resolves the configured built-in preset when the toggle is on', + () async { + final preset = await load( + const AppSettings( + applyDefaultTankToImports: true, + defaultTankPreset: 'al80', + ), + ); + + expect(preset?.name, 'al80'); + expect(preset?.volumeLiters, 11.1); + }, + ); + + test('yields nothing for a preset that no longer exists', () async { + final preset = await load( + const AppSettings( + applyDefaultTankToImports: true, + defaultTankPreset: 'deleted-custom-tank', + ), + ); + + expect(preset, isNull); + }); +} diff --git a/test/features/dive_log/domain/entities/dive_sac_fix_test.dart b/test/features/dive_log/domain/entities/dive_sac_fix_test.dart index 0a529a98bc..7478dd9745 100644 --- a/test/features/dive_log/domain/entities/dive_sac_fix_test.dart +++ b/test/features/dive_log/domain/entities/dive_sac_fix_test.dart @@ -36,6 +36,7 @@ const _singleTank = DiveTank( ); void main() { + sacReferenceTankTests(); // ───────────────────────────────────────────────────────────────────────── // Dive.sac (L/min at surface) // ───────────────────────────────────────────────────────────────────────── @@ -414,3 +415,33 @@ void main() { }); }); } + +// ───────────────────────────────────────────────────────────────────────── +// Dive.sacReferenceTank (the cylinder the pressure lane reads) +// ───────────────────────────────────────────────────────────────────────── +void sacReferenceTankTests() { + group('Dive.sacReferenceTank', () { + const backGas = DiveTank(id: 'bg', role: TankRole.backGas); + const stage = DiveTank(id: 'st', role: TankRole.stage, volume: 11.1); + + test('is null with no cylinders', () { + expect(_sacDive().sacReferenceTank, isNull); + }); + + test('is the only cylinder on a single-tank dive, whatever its role', () { + expect(_sacDive(tanks: const [stage]).sacReferenceTank?.id, 'st'); + }); + + test('is the back gas on a multi-tank dive even when listed later', () { + expect( + _sacDive(tanks: const [stage, backGas]).sacReferenceTank?.id, + 'bg', + ); + }); + + test('falls back to the first cylinder when none is back gas', () { + const deco = DiveTank(id: 'dc', role: TankRole.deco); + expect(_sacDive(tanks: const [stage, deco]).sacReferenceTank?.id, 'st'); + }); + }); +} diff --git a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart index a05328a608..e96ba35e8a 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/constants/enums.dart'; @@ -196,6 +197,59 @@ void main() { expect(find.byType(SacVolumeHint), findsNothing); }); + testWidgets('tapping the hint opens the dive editor', (tester) async { + final dive = reportedDive(volume: null); + final base = await getBaseOverrides( + settingsNotifier: MockSettingsNotifier( + const AppSettings(sacUnit: SacUnit.litersPerMin), + ), + ); + final router = GoRouter( + initialLocation: '/test', + routes: [ + GoRoute( + path: '/test', + builder: (context, state) => + DiveDetailPage(diveId: dive.id, embedded: true), + ), + GoRoute( + path: '/dives/:id/edit', + builder: (context, state) => + Scaffold(body: Text('EDIT_STUB ${state.pathParameters['id']}')), + ), + ], + ); + final originalOnError = FlutterError.onError; + FlutterError.onError = (_) {}; + addTearDown(() => FlutterError.onError = originalOnError); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...base, + diveProvider(dive.id).overrideWith((ref) async => dive), + ], + child: MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + final hint = find.byType(SacVolumeHint); + expect(hint, findsOneWidget); + await tester.ensureVisible(hint); + await tester.pump(); + await tester.tap(hint); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('EDIT_STUB ${dive.id}'), findsOneWidget); + }); + testWidgets('hides the row when there is no pressure data either', ( tester, ) async { diff --git a/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart b/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart index 1e53031715..18fc177fb4 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart @@ -20,11 +20,45 @@ import 'package:submersion/l10n/arb/app_localizations.dart'; import '../../../../helpers/mock_providers.dart'; -/// The SAC-by-segment card converts its bar/min segments to L/min with the -/// dive's cylinder volume. Without one it silently showed bar/min under an -/// L/min preference (issue #386); now it says so. +/// The SAC-by-segment card converts its bar/min segments to L/min with a +/// cylinder volume. Without one it silently showed bar/min under an L/min +/// preference (issue #386); now it says so. The volume comes from the +/// segment's own cylinder when it is attributed, otherwise from the same +/// reference (back gas) cylinder the pressure lane reads; never from an +/// unrelated bottle that happens to have a size. void main() { - Dive diveWithProfile({double? tankVolume}) { + const backGasNoVolume = DiveTank( + id: 'back-gas', + startPressure: 200.0, + endPressure: 50.0, + gasMix: GasMix(), + role: TankRole.backGas, + ); + const backGasWithVolume = DiveTank( + id: 'back-gas', + volume: 12.0, + startPressure: 200.0, + endPressure: 50.0, + gasMix: GasMix(), + role: TankRole.backGas, + ); + const stageWithVolume = DiveTank( + id: 'stage', + volume: 11.1, + gasMix: GasMix(o2: 50.0), + role: TankRole.stage, + order: 1, + ); + const decoNoVolume = DiveTank( + id: 'deco', + startPressure: 200.0, + endPressure: 150.0, + gasMix: GasMix(o2: 50.0), + role: TankRole.deco, + order: 1, + ); + + Dive diveWithProfile({required List tanks}) { return createTestDiveWithBottomTime().copyWith( profile: List.generate( 6, @@ -33,22 +67,13 @@ void main() { depth: (i < 3 ? i * 8.0 : (5 - i) * 8.0), ), ), - tanks: [ - DiveTank( - id: 'tank-1', - volume: tankVolume, - startPressure: 200.0, - endPressure: 50.0, - gasMix: const GasMix(), - role: TankRole.backGas, - ), - ], + tanks: tanks, ); } - ProfileAnalysis analysisWithSacSegments() { + ProfileAnalysis analysisWithSacSegments({String? tankId}) { return ProfileAnalysis.empty().copyWith( - sacSegments: const [ + sacSegments: [ SacSegment( startTimestamp: 0, endTimestamp: 300, @@ -58,6 +83,7 @@ void main() { sacRate: 0.8, gasConsumed: 4.0, segmentationType: SacSegmentationType.timeInterval, + tankId: tankId, ), ], ); @@ -67,6 +93,7 @@ void main() { WidgetTester tester, { required Dive dive, required AppSettings settings, + String? segmentTankId, }) async { final base = await getBaseOverrides( settingsNotifier: MockSettingsNotifier(settings), @@ -86,9 +113,9 @@ void main() { diveDataSourcesProvider( dive.id, ).overrideWith((ref) async => []), - profileAnalysisProvider( - dive.id, - ).overrideWith((ref) async => analysisWithSacSegments()), + profileAnalysisProvider(dive.id).overrideWith( + (ref) async => analysisWithSacSegments(tankId: segmentTankId), + ), selectedSegmentationProvider.overrideWith( (ref) => SacSegmentationType.timeInterval, ), @@ -114,7 +141,7 @@ void main() { await tester.pump(const Duration(seconds: 1)); } - Finder hintInSacCard(WidgetTester tester) { + Finder sacCard(WidgetTester tester) { final l10n = AppLocalizations.of( tester.element(find.byType(DiveDetailPage)), ); @@ -123,29 +150,44 @@ void main() { l10n.diveLog_detail_section_sacRateBySegment, ); expect(card, findsOneWidget); - return find.descendant(of: card, matching: find.byType(SacVolumeHint)); + return card; } + Finder hintIn(Finder card) => + find.descendant(of: card, matching: find.byType(SacVolumeHint)); + + /// A segment VALUE in [unit] (e.g. "0.8 bar/min"); the hint's own text + /// also mentions L/min, so this must not match prose. + Finder unitIn(Finder card, String unit) => find.descendant( + of: card, + matching: find.textContaining(RegExp('^\\d+(\\.\\d+)? $unit/min\$')), + ); + testWidgets('explains the bar/min fallback when L/min is selected', ( tester, ) async { await pumpWith( tester, - dive: diveWithProfile(), + dive: diveWithProfile(tanks: const [backGasNoVolume]), settings: const AppSettings(sacUnit: SacUnit.litersPerMin), ); - expect(hintInSacCard(tester), findsOneWidget); + final card = sacCard(tester); + expect(hintIn(card), findsOneWidget); + expect(unitIn(card, 'bar'), findsWidgets); + expect(unitIn(card, 'L'), findsNothing); }); testWidgets('shows no hint once the cylinder has a volume', (tester) async { await pumpWith( tester, - dive: diveWithProfile(tankVolume: 12.0), + dive: diveWithProfile(tanks: const [backGasWithVolume]), settings: const AppSettings(sacUnit: SacUnit.litersPerMin), ); - expect(hintInSacCard(tester), findsNothing); + final card = sacCard(tester); + expect(hintIn(card), findsNothing); + expect(unitIn(card, 'L'), findsWidgets); }); testWidgets('shows no hint under a pressure-per-minute preference', ( @@ -153,10 +195,46 @@ void main() { ) async { await pumpWith( tester, - dive: diveWithProfile(), + dive: diveWithProfile(tanks: const [backGasNoVolume]), settings: const AppSettings(sacUnit: SacUnit.pressurePerMin), ); - expect(hintInSacCard(tester), findsNothing); + expect(hintIn(sacCard(tester)), findsNothing); + }); + + testWidgets('does not borrow a stage bottle\'s volume for the back gas', ( + tester, + ) async { + // Only the stage has a size; the segments describe the back gas. They + // must stay in bar/min, with the hint, rather than be converted with a + // cylinder that never fed them. + await pumpWith( + tester, + dive: diveWithProfile(tanks: const [backGasNoVolume, stageWithVolume]), + settings: const AppSettings(sacUnit: SacUnit.litersPerMin), + ); + + final card = sacCard(tester); + expect(hintIn(card), findsOneWidget); + expect(unitIn(card, 'bar'), findsWidgets); + expect(unitIn(card, 'L'), findsNothing); + }); + + testWidgets('does not borrow the back gas volume for an attributed deco ' + 'segment', (tester) async { + // A gas-switch segment attributed to the deco bottle, which has no size, + // while the back gas does: that segment stays in bar/min and the hint + // points at the missing volume. + await pumpWith( + tester, + dive: diveWithProfile(tanks: const [backGasWithVolume, decoNoVolume]), + settings: const AppSettings(sacUnit: SacUnit.litersPerMin), + segmentTankId: 'deco', + ); + + final card = sacCard(tester); + expect(hintIn(card), findsOneWidget); + expect(unitIn(card, 'bar'), findsWidgets); + expect(unitIn(card, 'L'), findsNothing); }); } From 77ad8ee3e980edd28e29b7b761cce901b33ccf51 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 12:52:46 -0400 Subject: [PATCH 100/122] fix(location): a non-200 from Nominatim is an outage, not "nothing found" (#1187) Nominatim reports "nothing here" with HTTP 200 and an error body, so a non-200 (rate limit, outage, blocked user agent) is the service itself. The address lookup now returns PlaceLookup.unavailable for it, so the site form shows the connection message and the bulk backfill aborts as offline instead of counting every site as unchanged. The natural-layer request stays best-effort and only drops the body of water. Docs note that the column landed as schema v166 after the merge with main. --- ...26-08-26-site-location-from-coordinates.md | 5 +++ ...5-site-location-from-coordinates-design.md | 5 +++ lib/core/services/location_service.dart | 33 +++++++++++++---- test/core/services/location_service_test.dart | 37 +++++++++++++++++-- test/helpers/fake_nominatim.dart | 16 +++++++- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md b/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md index dbc2788660..1c2e30d7b3 100644 --- a/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md +++ b/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md @@ -1,5 +1,10 @@ # Site Location From Coordinates Implementation Plan +> **Schema version:** the column landed as **v166**, not v162. `origin/main` +> claimed v163 while this branch was open and v164/v165 were reserved for two +> other open PRs, so the migration, helper, ladder entry and test were +> renumbered in the merge commit. Every "v162" below is the number as planned. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fill town and body of water (not only country and region) from a dive site's coordinates, in a synced per-diver language, with a per-site "Look up from coordinates" action and a bulk "Fill in missing location details" pass that never overwrites existing values. diff --git a/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md b/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md index 777e0b38d6..848a2e4f0d 100644 --- a/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md +++ b/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md @@ -1,5 +1,10 @@ # Dive Site Location From Coordinates: Design +> **Schema version:** the column landed as **v166**, not v162. `origin/main` +> claimed v163 while this branch was open and v164/v165 were reserved for two +> other open PRs, so the migration, helper, ladder entry and test were +> renumbered in the merge commit. Every "v162" below is the number as planned. + **Status:** approved 2026-08-25 **Issue:** #1187 **Branches:** `worktree-issue-1187-site-field-wipe` (PR A, bounded fix) and diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index a4ad4a69f1..d9973f8fd2 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -13,6 +13,18 @@ import 'package:submersion/core/services/logger_service.dart'; /// Check if we're on a mobile platform (iOS/Android) bool get _isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid); +/// Nominatim answered with something other than 200. "Nothing here" is a +/// 200 with an error body, so a non-200 is the service itself (rate limit, +/// outage, blocked user agent), which callers must not mistake for "no +/// location details found". +class _NominatimStatusException implements Exception { + const _NominatimStatusException(this.statusCode); + final int statusCode; + + @override + String toString() => 'Nominatim responded with HTTP $statusCode'; +} + /// Result of a location capture class LocationResult { final double latitude; @@ -343,7 +355,7 @@ class LocationService { buildReverseGeocodeUri(latitude, longitude, languageCode: languageCode), languageCode, ); - final address = json?['address'] as Map?; + final address = json['address'] as Map?; if (address == null) return const PlaceLookup.empty(); final country = address['country'] as String?; @@ -361,6 +373,9 @@ class LocationService { } on SocketException catch (e) { _log.warning('Web reverse geocoding unreachable: $e'); return const PlaceLookup.unavailable(); + } on _NominatimStatusException catch (e) { + _log.warning('Web reverse geocoding refused: $e'); + return const PlaceLookup.unavailable(); } catch (e) { _log.warning('Web reverse geocoding failed: $e'); return const PlaceLookup.empty(); @@ -388,7 +403,6 @@ class LocationService { buildNaturalFeatureUri(latitude, longitude, languageCode: languageCode), languageCode, ); - if (json == null) return null; final water = bodyOfWaterFromNaturalFeature(json); _log.info('Natural layer: ${water ?? 'no water feature'}'); return water; @@ -398,11 +412,12 @@ class LocationService { } } - /// One Nominatim GET. Returns the decoded object, or null for a non-200 - /// status. Lets socket errors propagate so callers can tell "offline" from - /// "nothing there". The client is closed in a finally so its sockets are - /// released even when the body or the JSON decode throws. - Future?> _fetchNominatimJson( + /// One Nominatim GET, decoded. Throws [_NominatimStatusException] on a + /// non-200 status and lets socket errors propagate, so callers can tell + /// "offline" and "refused" from "nothing there". The client is closed in a + /// finally so its sockets are released even when the body or the JSON + /// decode throws. + Future> _fetchNominatimJson( Uri url, String languageCode, ) async { @@ -413,7 +428,9 @@ class LocationService { final request = await client.getUrl(url); request.headers.set('Accept-Language', languageCode); final response = await request.close(); - if (response.statusCode != 200) return null; + if (response.statusCode != 200) { + throw _NominatimStatusException(response.statusCode); + } final body = await response.transform(utf8.decoder).join(); return jsonDecode(body) as Map; } finally { diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index b429e87ba8..2e19051f90 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -189,7 +189,11 @@ void main() { }, ); - test('returns empty fields on a non-200 response', () async { + test('reports the geocoder as unavailable on a non-200 response', () async { + // Nominatim says "nothing here" with a 200 and an error body, so a + // non-200 is the service itself: a rate limit or an outage, which must + // not read as "no location details found" (and must not count as + // unchanged in the bulk backfill). final server = FakeNominatim( statusCode: 503, body: 'Service Unavailable', @@ -199,9 +203,18 @@ void main() { () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), ); - expect(result.country, isNull); - expect(result.region, isNull); - expect(result.locality, isNull); + expect(result.isEmpty, isTrue); + expect(result.networkFailed, isTrue); + }); + + test('a rate limit is reported the same way', () async { + final server = FakeNominatim(statusCode: 429, body: 'Too Many Requests'); + + final result = await server.run( + () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), + ); + + expect(result.networkFailed, isTrue); }); test('swallows malformed JSON instead of throwing', () async { @@ -627,6 +640,22 @@ void main() { ); }); + test('a non-200 on the natural layer keeps the address result', () async { + final server = FakeNominatim( + body: jsonEncode(address()), + statusFor: (uri) => + uri.queryParameters['layer'] == 'natural' ? 503 : null, + ); + + final result = await server.run( + () => service.reverseGeocode(47.027631, 8.400640, languageCode: 'en'), + ); + + expect(result.country, 'Switzerland'); + expect(result.bodyOfWater, isNull); + expect(result.networkFailed, isFalse); + }); + test('a failing natural-layer request keeps the address result', () async { var calls = 0; final server = FakeNominatim( diff --git a/test/helpers/fake_nominatim.dart b/test/helpers/fake_nominatim.dart index 6b911413a4..e8a5570392 100644 --- a/test/helpers/fake_nominatim.dart +++ b/test/helpers/fake_nominatim.dart @@ -17,7 +17,12 @@ import 'dart:io'; /// language the caller asked for (issue #1187), which must reach both the URI /// and the request headers. class FakeNominatim { - FakeNominatim({this.statusCode = 200, this.body = '{}', this.bodyFor}); + FakeNominatim({ + this.statusCode = 200, + this.body = '{}', + this.bodyFor, + this.statusFor, + }); final int statusCode; final String body; @@ -25,7 +30,11 @@ class FakeNominatim { /// When set, wins over [body] for the given request. final String? Function(Uri uri)? bodyFor; + /// When set, wins over [statusCode] for the given request. + final int? Function(Uri uri)? statusFor; + String bodyForUri(Uri uri) => bodyFor?.call(uri) ?? body; + int statusForUri(Uri uri) => statusFor?.call(uri) ?? statusCode; final List requestedUris = []; final List> requestHeaders = >[]; @@ -93,7 +102,10 @@ class FakeHttpClientRequest implements HttpClientRequest { @override Future close() async { _server.requestHeaders.add((headers as FakeHttpHeaders).values); - return FakeHttpClientResponse(_server.statusCode, _server.bodyForUri(uri)); + return FakeHttpClientResponse( + _server.statusForUri(uri), + _server.bodyForUri(uri), + ); } @override From 98450e5b9d8229ba308bbaa98bb11f1e7b5f9cbe Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 12:57:49 -0400 Subject: [PATCH 101/122] fix(sync): surface the column a conflict actually differs on Review round. Five findings, three of them behavioral. The preview only appended non-preferred columns when the record had no preferred field at all. A dive whose two versions share a name but disagree on diveNumber therefore showed the name and nothing else, leaving the user with no basis for the choice the dialog exists to offer. Each side now receives the other as a counterpart and always shows the columns whose values differ. A first attempt showed only the differing columns and broke four tests, which was the right answer: difference and context are both needed. A finding row whose sides differ only in severity still has to say which detector it is. Preferred fields lead, differing columns follow, and a record with no preferred field then fills up with its remaining columns as before. _conflictTitle read name and title from the local side only, so a conflict whose local row is already gone fell back to "$entityType #$id" even when the remote version carried a perfectly good name. Either side can supply it now. _formatSeconds rendered anything under a minute as "0min", so two versions differing only in a sub-minute duration displayed identically. They now keep their seconds. The doc comment claimed parity with DiveFieldFormatter, which renders those as "--"; that is right for a dive summary and wrong here, and the comment now says why rather than claiming a parity it does not have. Two documentation fixes, both comment rot introduced earlier in this branch. The preview's class and function comments still described the original references-first ordering, which changed when preferred fields moved ahead of references. And the foreign-key map claimed every entry was transcribed from a `.references(Table, #id)` clause, when several columns (Media.subscriptionId and connectorAccountId, DiveDiveTypes.diveTypeId, DivePlanSegments.switchToTankId, DiveProfileEvents.tankId) hold an id without a declared Drift constraint. Both now describe what the code does. --- .../services/sync/conflict_reference.dart | 14 +++- .../widgets/conflict_data_preview.dart | 84 ++++++++++++++++--- .../widgets/conflict_resolution_dialog.dart | 17 +++- .../conflict_resolution_dialog_test.dart | 42 ++++++++++ .../widgets/conflict_scalar_format_test.dart | 8 ++ 5 files changed, 147 insertions(+), 18 deletions(-) diff --git a/lib/core/services/sync/conflict_reference.dart b/lib/core/services/sync/conflict_reference.dart index 4c62a98796..3e96404443 100644 --- a/lib/core/services/sync/conflict_reference.dart +++ b/lib/core/services/sync/conflict_reference.dart @@ -61,9 +61,17 @@ class ConflictReferenceResolver { /// same diver, dive or site, so each referenced row is read once. final Map?> _rows = {}; - /// Foreign-key column -> sync entity type, transcribed from the - /// `.references(Table, #id)` clauses in `database.dart`. Columns whose name - /// is ambiguous across tables are disambiguated by [_targetOverrides]. + /// Foreign-key column -> sync entity type. + /// + /// Most entries are transcribed from the `.references(Table, #id)` clauses + /// in `database.dart`. The rest are columns that hold another row's id + /// without a declared Drift constraint (`Media.subscriptionId` and + /// `connectorAccountId`, `DiveDiveTypes.diveTypeId`, + /// `DivePlanSegments.switchToTankId`, `DiveProfileEvents.tankId`); they are + /// listed here because the dialog can resolve them just as well, so verify + /// those against their table rather than expecting a `references` clause. + /// Columns whose name is ambiguous across tables are disambiguated by + /// [_targetOverrides]. static const _defaultTargets = { 'diveId': 'dives', 'relatedDiveId': 'dives', diff --git a/lib/features/settings/presentation/widgets/conflict_data_preview.dart b/lib/features/settings/presentation/widgets/conflict_data_preview.dart index 1932f4cdba..e255de10a8 100644 --- a/lib/features/settings/presentation/widgets/conflict_data_preview.dart +++ b/lib/features/settings/presentation/widgets/conflict_data_preview.dart @@ -50,20 +50,27 @@ const _preferredFields = [ 'notes', ]; -/// A record's data preview: resolved references first, then the fields that -/// distinguish the two versions. +/// A record's data preview: the record's own recognizable fields, then its +/// resolved references, then whatever column the two versions disagree about. +/// A junction row has no recognizable field of its own, so its references +/// lead. class ConflictDataPreview extends ConsumerWidget { const ConflictDataPreview({ super.key, required this.entityType, required this.data, required this.references, + this.counterpart = const {}, }); final String entityType; final Map data; final List references; + /// The other side of the same conflict, so the preview can surface the + /// columns the two versions disagree about. + final Map counterpart; + @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -80,6 +87,7 @@ class ConflictDataPreview extends ConsumerWidget { entityType: entityType, data: data, references: references, + counterpart: counterpart, ); return Column( @@ -113,15 +121,21 @@ class ConflictDataPreview extends ConsumerWidget { /// Builds the preview lines for one side of a conflict. /// -/// References come first because for a junction entity they are the only -/// real-world content the record has; the remaining columns follow, with -/// bookkeeping and already-rendered fields dropped. +/// Order is: the record's own preferred fields, its resolved references, then +/// the columns whose value differs from [counterpart] (the other side of the +/// same conflict). A junction entity has no preferred field, so its references +/// lead. Bookkeeping and already-rendered columns are dropped throughout. +/// +/// [counterpart] is what makes the differing column visible: a dive whose two +/// versions share a name but disagree on `diveNumber` would otherwise show +/// only the name, leaving nothing to choose between. List conflictPreviewRows({ required AppLocalizations l10n, required UnitFormatter units, required String entityType, required Map data, required List references, + Map counterpart = const {}, }) { final message = entityType == 'qualityFindings' ? _findingMessage(l10n, units, data) @@ -136,6 +150,27 @@ List conflictPreviewRows({ for (final reference in references) reference.field, }; final preferred = _preferredScalars(data, hidden); + final differing = _differingScalars( + data, + counterpart, + hidden, + preferred.keys.toSet(), + ); + + // Difference and context are both needed: the differing column is what the + // user is choosing between, but a junction or finding row still has to say + // what it is. Preferred fields lead, the differing columns follow, and a + // record with no preferred field then fills up with its remaining columns. + final scalars = {...preferred}; + for (final entry in differing.entries) { + scalars.putIfAbsent(entry.key, () => entry.value); + } + if (preferred.isEmpty) { + for (final entry in _remainingScalars(data, hidden).entries) { + if (scalars.length >= 6) break; + scalars.putIfAbsent(entry.key, () => entry.value); + } + } ConflictPreviewRow scalarRow(MapEntry entry) => ( label: entry.key, @@ -160,10 +195,9 @@ List conflictPreviewRows({ )); } - if (preferred.isEmpty) { - for (final entry in _remainingScalars(data, hidden).entries) { - rows.add(scalarRow(entry)); - } + for (final entry in scalars.entries) { + if (preferred.containsKey(entry.key)) continue; // already led the preview + rows.add(scalarRow(entry)); } return rows; } @@ -218,9 +252,16 @@ String formatConflictScalar( return value.toString(); } -/// A stored count of seconds as "1h 5m" or "45min", matching how the dive -/// field formatter renders a duration elsewhere. +/// A stored count of seconds as "1h 5m" or "45min". +/// +/// Anything under a minute keeps its seconds instead of collapsing to "0min". +/// The dive field formatter renders those as "--" (unavailable), which is +/// right for a dive summary and wrong here: two versions differing only in a +/// sub-minute value would render identically in the one dialog whose whole +/// job is telling them apart. A negative value takes the same path and shows +/// itself rather than wrapping into a plausible-looking positive minute count. String _formatSeconds(int seconds) { + if (seconds < 60) return '${seconds}s'; final totalMinutes = seconds ~/ 60; final hours = totalMinutes ~/ 60; final minutes = totalMinutes % 60; @@ -248,6 +289,27 @@ Map _preferredScalars( if (data.containsKey(key) && _usable(data, hidden, key)) key: data[key], }; +/// Columns this side disagrees with the other side about. These are the ones +/// a user is actually choosing between, so they are shown even when they are +/// not on the preferred list. +Map _differingScalars( + Map data, + Map counterpart, + Set hidden, + Set alreadyShown, +) { + final differing = {}; + for (final entry in data.entries) { + if (differing.length >= 5) break; + if (alreadyShown.contains(entry.key)) continue; + if (!_usable(data, hidden, entry.key)) continue; + if (counterpart[entry.key] != entry.value) { + differing[entry.key] = entry.value; + } + } + return differing; +} + /// Nothing recognizable: show the first few columns that survived the filter, /// which for a junction row is what is left after its foreign keys. Map _remainingScalars( diff --git a/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart b/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart index d643c1ec87..e01c6283fa 100644 --- a/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart +++ b/lib/features/settings/presentation/widgets/conflict_resolution_dialog.dart @@ -203,6 +203,7 @@ class _ConflictResolutionDialogState entityType: conflict.entityType, data: conflict.localData, references: conflict.localReferences, + counterpart: conflict.remoteData, ), ], ), @@ -240,6 +241,7 @@ class _ConflictResolutionDialogState entityType: conflict.entityType, data: conflict.remoteData, references: conflict.remoteReferences, + counterpart: conflict.localData, ), ], ), @@ -252,16 +254,23 @@ class _ConflictResolutionDialogState /// Names the record a user is being asked about. Junction and relation /// entities have no name of their own, so they are named by the records they /// point at; only a record that resolved to nothing falls back to its id. + /// + /// Either side can supply the name. When the local row is already gone the + /// remote one is all there is, and an id-based title would be a worse answer + /// than the name sitting in the version being offered. String _conflictTitle(SyncConflict conflict) { - final own = - conflict.localData['name'] as String? ?? - conflict.localData['title'] as String?; - if (own != null && own.isNotEmpty) return own; + final own = _ownName(conflict.localData) ?? _ownName(conflict.remoteData); + if (own != null) return own; return conflictReferenceSummary(conflict.localReferences) ?? conflictReferenceSummary(conflict.remoteReferences) ?? conflict.displayName; } + String? _ownName(Map data) { + final name = data['name'] as String? ?? data['title'] as String?; + return (name != null && name.isNotEmpty) ? name : null; + } + Widget _buildResolutionOptions(BuildContext context, SyncConflict conflict) { final key = _conflictKey(conflict); final selected = _resolutions[key]; diff --git a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart index edf94586a0..cd6054120e 100644 --- a/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart +++ b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart @@ -333,6 +333,48 @@ void main() { expect(find.text('detectorId:'), findsNWidgets(2)); }); + testWidgets('shows the column that actually differs between the sides', ( + tester, + ) async { + // The whole point of the dialog is choosing between two versions. A + // record with a recognizable field must not hide the column the two sides + // disagree about just because that column is not on the preferred list. + await pumpDialog( + tester, + SyncConflict( + entityType: 'dives', + recordId: 'd-1', + localData: const {'id': 'd-1', 'name': 'Blue Hole', 'diveNumber': 12}, + remoteData: const {'id': 'd-1', 'name': 'Blue Hole', 'diveNumber': 13}, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + expect(find.text('diveNumber:'), findsNWidgets(2)); + expect(find.text('12'), findsOneWidget); + expect(find.text('13'), findsOneWidget); + }); + + testWidgets('names a conflict from the remote side when the local row is ' + 'gone', (tester) async { + await pumpDialog( + tester, + SyncConflict( + entityType: 'diveSites', + recordId: 's-1', + localData: const {}, + remoteData: const {'id': 's-1', 'name': 'The Arch'}, + localModified: DateTime(2026, 3, 28), + remoteModified: DateTime(2026, 3, 29), + ), + ); + + // Once in the header, once in the remote preview. + expect(find.text('The Arch'), findsNWidgets(2)); + expect(find.textContaining('diveSites #'), findsNothing); + }); + testWidgets('renders a detector that dates its finding', (tester) async { // A second detector, to show the preview inherits every detector's copy // from the data-quality renderer rather than special-casing depth spikes. diff --git a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart index ee8464066f..fce628d853 100644 --- a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart +++ b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart @@ -45,6 +45,14 @@ void main() { expect(format(units, 'runtime', 4500), '1h 15m'); }); + test('keeps a sub-minute duration distinguishable', () { + // Rounding these to "0min" would make two different stored values look + // identical, which is exactly the choice the dialog exists to support. + expect(format(units, 'bottomTime', 30), '30s'); + expect(format(units, 'bottomTime', 45), '45s'); + expect(format(units, 'bottomTime', 0), '0s'); + }); + test('renders a flag as words', () { expect(format(units, 'isShared', true), 'Yes'); expect(format(units, 'isShared', false), 'No'); From 4dfe21e2e26d819fe3b8279436f7b3931888fab8 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 13:03:15 -0400 Subject: [PATCH 102/122] fix(sites): say when the geocoder was unreachable after Use my location or Pick from map (#1187) --- .../presentation/pages/site_edit_page.dart | 10 ++++++++-- .../pages/site_edit_fill_location_test.dart | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/features/dive_sites/presentation/pages/site_edit_page.dart b/lib/features/dive_sites/presentation/pages/site_edit_page.dart index cb950cd6ec..e5eb49e491 100644 --- a/lib/features/dive_sites/presentation/pages/site_edit_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_edit_page.dart @@ -1416,10 +1416,14 @@ class _SiteEditPageState extends ConsumerState { }); if (mounted) { + // The position landed in the form either way; say so, but when the + // geocoder was unreachable explain why the place fields stayed empty. ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - result.accuracy != null + result.place.networkFailed + ? context.l10n.diveSites_edit_snackbar_lookupFailed + : result.accuracy != null ? context.l10n .diveSites_edit_snackbar_locationCapturedWithAccuracy( result.accuracy!.toStringAsFixed(0), @@ -1462,7 +1466,9 @@ class _SiteEditPageState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.diveSites_edit_snackbar_locationSelectedFromMap, + result.place.networkFailed + ? context.l10n.diveSites_edit_snackbar_lookupFailed + : context.l10n.diveSites_edit_snackbar_locationSelectedFromMap, ), ), ); diff --git a/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart b/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart index 8138ade7e3..c1d9b022b2 100644 --- a/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart +++ b/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart @@ -44,6 +44,7 @@ class _FakeLocationService implements LocationService { region: place.region, locality: place.locality, bodyOfWater: place.bodyOfWater, + geocodeUnavailable: place.networkFailed, ); @override @@ -156,4 +157,23 @@ void main() { expect(find.text('Weggis'), findsOneWidget); expect(find.text('Lake Lucerne'), findsOneWidget); }); + + testWidgets('Use my location reports an unreachable geocoder', ( + tester, + ) async { + await pumpEditor(tester, place: const PlaceLookup.unavailable()); + + await tester.tap(find.text('Add GPS position or altitude')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use My Location')); + await tester.pumpAndSettle(); + + // The coordinates still landed. + expect(find.text('47.027631'), findsOneWidget); + expect( + find.text('Location lookup failed. Check your connection and try again.'), + findsOneWidget, + ); + expect(find.textContaining('Location captured'), findsNothing); + }); } From f37b19f7ff4ac1c23160eb1d3d8d50f99b6f5822 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 13:25:09 -0400 Subject: [PATCH 103/122] fix(location): query-encode the language code; unawaited backfill flow; clarify the estimate (#1187) --- lib/core/services/location_service.dart | 9 ++++++--- .../presentation/pages/site_list_page.dart | 4 +++- .../widgets/site_list_content.dart | 6 ++++-- .../site_location_backfill_dialog.dart | 4 +++- test/core/services/location_service_test.dart | 20 +++++++++++++++++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index d9973f8fd2..6a4f89999b 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -73,14 +73,17 @@ class LocationService { /// value, so unchanged users keep grouping exactly as before. static const String defaultLanguageCode = 'en'; - /// Nominatim reverse-geocode URI for the address layer. + /// Nominatim reverse-geocode URI for the address layer. The language code + /// is user data (a synced setting), so it is query-encoded rather than + /// interpolated, which keeps a stray `&` from becoming a second parameter. static Uri buildReverseGeocodeUri( double latitude, double longitude, { required String languageCode, }) => Uri.parse( 'https://nominatim.openstreetmap.org/reverse?format=json' - '&lat=$latitude&lon=$longitude&zoom=10&accept-language=$languageCode', + '&lat=$latitude&lon=$longitude&zoom=10' + '&accept-language=${Uri.encodeQueryComponent(languageCode)}', ); /// Nominatim reverse-geocode URI for the natural layer, which answers with @@ -94,7 +97,7 @@ class LocationService { }) => Uri.parse( 'https://nominatim.openstreetmap.org/reverse?format=json' '&lat=$latitude&lon=$longitude&zoom=14&layer=natural' - '&accept-language=$languageCode', + '&accept-language=${Uri.encodeQueryComponent(languageCode)}', ); /// The name of a water feature from a natural-layer answer, or null when diff --git a/lib/features/dive_sites/presentation/pages/site_list_page.dart b/lib/features/dive_sites/presentation/pages/site_list_page.dart index 7f138fe3d2..b488f2c211 100644 --- a/lib/features/dive_sites/presentation/pages/site_list_page.dart +++ b/lib/features/dive_sites/presentation/pages/site_list_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -180,7 +182,7 @@ class _SiteListPageState extends ConsumerState { ); ref.read(siteListViewModeProvider.notifier).state = mode; } else if (value == 'fill_location_details') { - showSiteLocationBackfillFlow(context, ref); + unawaited(showSiteLocationBackfillFlow(context, ref)); } }, itemBuilder: (context) { diff --git a/lib/features/dive_sites/presentation/widgets/site_list_content.dart b/lib/features/dive_sites/presentation/widgets/site_list_content.dart index 2df3cd80af..9237fac9d6 100644 --- a/lib/features/dive_sites/presentation/widgets/site_list_content.dart +++ b/lib/features/dive_sites/presentation/widgets/site_list_content.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:go_router/go_router.dart'; @@ -521,7 +523,7 @@ class _SiteListContentState extends ConsumerState { } else if (value == 'import') { context.push('/sites/import'); } else if (value == 'fill_location_details') { - showSiteLocationBackfillFlow(context, ref); + unawaited(showSiteLocationBackfillFlow(context, ref)); } else if (value.startsWith('view_')) { final mode = ListViewMode.fromName( value.replaceFirst('view_', ''), @@ -797,7 +799,7 @@ class _SiteListContentState extends ConsumerState { } else if (value == 'import') { context.push('/sites/import'); } else if (value == 'fill_location_details') { - showSiteLocationBackfillFlow(context, ref); + unawaited(showSiteLocationBackfillFlow(context, ref)); } else if (value.startsWith('view_')) { final mode = ListViewMode.fromName( value.replaceFirst('view_', ''), diff --git a/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart b/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart index 48ab1334bf..ba8a2a3f6e 100644 --- a/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart +++ b/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart @@ -4,7 +4,9 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_location_backfill_provider.dart'; import 'package:submersion/l10n/l10n_extension.dart'; -/// Seconds per site: two Nominatim requests, one second apart. +/// Seconds per site for the estimate: up to two Nominatim requests (address +/// layer, then natural layer), one second apart. On mobile the address may +/// come from the platform geocoder instead, so this is an upper bound. const int _secondsPerSite = 2; /// The bulk "fill in missing location details" flow (issue #1187): diff --git a/test/core/services/location_service_test.dart b/test/core/services/location_service_test.dart index 2e19051f90..2fd8a7b65d 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -42,6 +42,26 @@ void main() { expect(uri.queryParameters['lon'], '-5.6'); }); + test('the language code is query-encoded, never interpolated', () { + // The code is synced user data; a stray separator must not become a + // second query parameter. + for (final uri in [ + LocationService.buildReverseGeocodeUri( + 36.0, + -5.6, + languageCode: 'en&foo=bar', + ), + LocationService.buildNaturalFeatureUri( + 36.0, + -5.6, + languageCode: 'en&foo=bar', + ), + ]) { + expect(uri.queryParameters['accept-language'], 'en&foo=bar'); + expect(uri.queryParameters.containsKey('foo'), isFalse); + } + }); + test('forward geocode URI carries accept-language=en', () { final uri = LocationService.buildForwardGeocodeUri('Blue Hole'); expect(uri.queryParameters['accept-language'], 'en'); From ff436f4a4b64450d248440494ce794741d731819 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:30:32 +0000 Subject: [PATCH 104/122] Remove accidentally merged .github/workflows/claude.yml This file diverged from upstream/main during a prior merge and should not be part of this feature branch. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- .github/workflows/claude.yml | 42 ------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 83c427616c..0000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Claude Code -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] -jobs: - claude: - if: contains(github.event.comment.body, '@claude') - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: true - - - name: Read Flutter version - id: flutter-ver - run: echo "version=$(cat .github/flutter-version.txt)" >> "$GITHUB_OUTPUT" - - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ steps.flutter-ver.outputs.version }} - channel: 'stable' - - - name: Install dependencies - run: flutter pub get - - - name: Run code generation - run: dart run build_runner build --delete-conflicting-outputs - - - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: | - --allowedTools "Bash(flutter pub get:*),Bash(flutter analyze:*),Bash(flutter test:*),Bash(dart format:*),Bash(git fetch:*),Bash(git merge:*),Bash(git rebase:*),Bash(git push:*),Bash(gh pr:*),Bash(gh issue comment:*)" From d061394a9934c4e3576ca92c1d0bf15b43c6082b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:33:30 +0000 Subject: [PATCH 105/122] Remove accidentally included .github/workflows/claude.yml Fork-specific CI infrastructure that does not exist upstream; it does not belong in this feature PR. Co-authored-by: alpheios-one <275321969+alpheios-one@users.noreply.github.com> --- .github/workflows/claude.yml | 42 ------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 6f00de519a..0000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Claude Code -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] -jobs: - claude: - if: contains(github.event.comment.body, '@claude') - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 1 - submodules: true - - - name: Read Flutter version - id: flutter-ver - run: echo "version=$(cat .github/flutter-version.txt)" >> "$GITHUB_OUTPUT" - - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ steps.flutter-ver.outputs.version }} - channel: 'stable' - - - name: Install dependencies - run: flutter pub get - - - name: Run code generation - run: dart run build_runner build --delete-conflicting-outputs - - - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: | - --allowedTools "Bash(flutter pub get:*),Bash(flutter analyze:*),Bash(flutter test:*),Bash(dart format:*)" From ca19db930bb9974833736d0ba76d504f8e223d1c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 13:34:15 -0400 Subject: [PATCH 106/122] fix(l10n): pluralize the backfill time estimate (#1187) --- 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_it.arb | 2 +- lib/l10n/arb/app_localizations.dart | 2 +- lib/l10n/arb/app_localizations_ar.dart | 9 ++++++++- 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_it.dart | 8 +++++++- lib/l10n/arb/app_localizations_nl.dart | 8 +++++++- lib/l10n/arb/app_localizations_pt.dart | 8 +++++++- lib/l10n/arb/app_nl.arb | 2 +- lib/l10n/arb/app_pt.arb | 2 +- .../widgets/site_location_backfill_dialog_test.dart | 11 +++++++++++ 20 files changed, 85 insertions(+), 19 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index bab356c64b..70769c5cde 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "تحديد المواقع", "diveSites_list_menu_fillLocationDetails": "إكمال تفاصيل الموقع الناقصة", "diveSites_backfill_confirm_title": "إكمال تفاصيل الموقع الناقصة؟", - "diveSites_backfill_confirm_body": "{count, plural, =1{موقع غوص واحد له إحداثيات ينقصه البلد أو المنطقة أو البلدة أو المسطح المائي.} other{{count} مواقع غوص لها إحداثيات ينقصها البلد أو المنطقة أو البلدة أو المسطح المائي.}} سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو {minutes} دقائق.", + "diveSites_backfill_confirm_body": "{count, plural, =1{موقع غوص واحد له إحداثيات ينقصه البلد أو المنطقة أو البلدة أو المسطح المائي.} other{{count} مواقع غوص لها إحداثيات ينقصها البلد أو المنطقة أو البلدة أو المسطح المائي.}} سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو {minutes, plural, =1{دقيقة واحدة} =2{دقيقتين} other{{minutes} دقائق}}.", "diveSites_backfill_confirm_start": "بدء", "diveSites_backfill_nothingToFill": "كل مواقع الغوص التي لها إحداثيات لديها تفاصيل الموقع بالفعل.", "diveSites_backfill_progress_title": "جارٍ إكمال تفاصيل الموقع", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 4f1eb2b935..04a468b6af 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "Tauchplätze auswählen", "diveSites_list_menu_fillLocationDetails": "Fehlende Ortsangaben ergänzen", "diveSites_backfill_confirm_title": "Fehlende Ortsangaben ergänzen?", - "diveSites_backfill_confirm_body": "{count, plural, =1{1 Tauchplatz mit Koordinaten hat kein Land, keine Region, keinen Ort oder kein Gewässer.} other{{count} Tauchplätze mit Koordinaten haben kein Land, keine Region, keinen Ort oder kein Gewässer.}} Submersion sucht jeden auf OpenStreetMap und füllt nur die leeren Felder aus. Das dauert etwa {minutes} Minuten.", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 Tauchplatz mit Koordinaten hat kein Land, keine Region, keinen Ort oder kein Gewässer.} other{{count} Tauchplätze mit Koordinaten haben kein Land, keine Region, keinen Ort oder kein Gewässer.}} Submersion sucht jeden auf OpenStreetMap und füllt nur die leeren Felder aus. Das dauert etwa {minutes, plural, =1{1 Minute} other{{minutes} Minuten}}.", "diveSites_backfill_confirm_start": "Starten", "diveSites_backfill_nothingToFill": "Alle Tauchplätze mit Koordinaten haben bereits ihre Ortsangaben.", "diveSites_backfill_progress_title": "Ortsangaben werden ergänzt", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ac8491bcd5..b5619deae2 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4944,7 +4944,7 @@ "diveSites_list_menu_select": "Select sites", "diveSites_list_menu_fillLocationDetails": "Fill in missing location details", "diveSites_backfill_confirm_title": "Fill in missing location details?", - "diveSites_backfill_confirm_body": "{count, plural, =1{1 site with coordinates has an empty country, region, town or body of water.} other{{count} sites with coordinates have an empty country, region, town or body of water.}} Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about {minutes} minutes.", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 site with coordinates has an empty country, region, town or body of water.} other{{count} sites with coordinates have an empty country, region, town or body of water.}} Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about {minutes, plural, =1{1 minute} other{{minutes} minutes}}.", "@diveSites_backfill_confirm_body": { "placeholders": { "count": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index aa20676fbf..6bda32f310 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "Seleccionar puntos", "diveSites_list_menu_fillLocationDetails": "Completar datos de ubicación que faltan", "diveSites_backfill_confirm_title": "¿Completar los datos de ubicación que faltan?", - "diveSites_backfill_confirm_body": "{count, plural, =1{1 punto de buceo con coordenadas no tiene país, región, localidad o masa de agua.} other{{count} puntos de buceo con coordenadas no tienen país, región, localidad o masa de agua.}} Submersion consultará cada uno en OpenStreetMap y rellenará solo los campos vacíos. Tarda unos {minutes} minutos.", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 punto de buceo con coordenadas no tiene país, región, localidad o masa de agua.} other{{count} puntos de buceo con coordenadas no tienen país, región, localidad o masa de agua.}} Submersion consultará cada uno en OpenStreetMap y rellenará solo los campos vacíos. Tarda {minutes, plural, =1{alrededor de 1 minuto} other{unos {minutes} minutos}}.", "diveSites_backfill_confirm_start": "Iniciar", "diveSites_backfill_nothingToFill": "Todos los puntos de buceo con coordenadas ya tienen sus datos de ubicación.", "diveSites_backfill_progress_title": "Completando datos de ubicación", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index af1ab7eddf..a4d57b295e 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "Sélectionner des sites", "diveSites_list_menu_fillLocationDetails": "Compléter les informations de lieu manquantes", "diveSites_backfill_confirm_title": "Compléter les informations de lieu manquantes ?", - "diveSites_backfill_confirm_body": "{count, plural, =1{1 site avec coordonnées n'a pas de pays, de région, de ville ou de plan d'eau.} other{{count} sites avec coordonnées n'ont pas de pays, de région, de ville ou de plan d'eau.}} Submersion recherchera chacun sur OpenStreetMap et ne remplira que les champs vides. Cela prend environ {minutes} minutes.", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 site avec coordonnées n'a pas de pays, de région, de ville ou de plan d'eau.} other{{count} sites avec coordonnées n'ont pas de pays, de région, de ville ou de plan d'eau.}} Submersion recherchera chacun sur OpenStreetMap et ne remplira que les champs vides. Cela prend environ {minutes, plural, =1{1 minute} other{{minutes} minutes}}.", "diveSites_backfill_confirm_start": "Démarrer", "diveSites_backfill_nothingToFill": "Tous les sites avec coordonnées ont déjà leurs informations de lieu.", "diveSites_backfill_progress_title": "Complément des informations de lieu", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index a72e1e6da4..b1b688986c 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "בחירת אתרים", "diveSites_list_menu_fillLocationDetails": "השלמת פרטי מיקום חסרים", "diveSites_backfill_confirm_title": "להשלים פרטי מיקום חסרים?", - "diveSites_backfill_confirm_body": "{count, plural, =1{לאתר אחד עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.} other{ל-{count} אתרים עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.}} Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך כ-{minutes} דקות.", + "diveSites_backfill_confirm_body": "{count, plural, =1{לאתר אחד עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.} other{ל-{count} אתרים עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.}} Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך {minutes, plural, =1{כדקה} other{כ-{minutes} דקות}}.", "diveSites_backfill_confirm_start": "התחלה", "diveSites_backfill_nothingToFill": "לכל האתרים עם קואורדינטות כבר יש פרטי מיקום.", "diveSites_backfill_progress_title": "משלים פרטי מיקום", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index db3ad491b2..c2db85400f 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "Seleziona siti", "diveSites_list_menu_fillLocationDetails": "Completa i dettagli di località mancanti", "diveSites_backfill_confirm_title": "Completare i dettagli di località mancanti?", - "diveSites_backfill_confirm_body": "{count, plural, =1{1 sito con coordinate non ha paese, regione, città o specchio d'acqua.} other{{count} siti con coordinate non hanno paese, regione, città o specchio d'acqua.}} Submersion cercherà ciascuno su OpenStreetMap e compilerà solo i campi vuoti. Richiede circa {minutes} minuti.", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 sito con coordinate non ha paese, regione, città o specchio d'acqua.} other{{count} siti con coordinate non hanno paese, regione, città o specchio d'acqua.}} Submersion cercherà ciascuno su OpenStreetMap e compilerà solo i campi vuoti. Richiede circa {minutes, plural, =1{1 minuto} other{{minutes} minuti}}.", "diveSites_backfill_confirm_start": "Avvia", "diveSites_backfill_nothingToFill": "Tutti i siti con coordinate hanno già i dettagli di località.", "diveSites_backfill_progress_title": "Completamento dei dettagli di località", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index ebfa10f79b..954678e363 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -14804,7 +14804,7 @@ abstract class AppLocalizations { /// No description provided for @diveSites_backfill_confirm_body. /// /// In en, this message translates to: - /// **'{count, plural, =1{1 site with coordinates has an empty country, region, town or body of water.} other{{count} sites with coordinates have an empty country, region, town or body of water.}} Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about {minutes} minutes.'** + /// **'{count, plural, =1{1 site with coordinates has an empty country, region, town or body of water.} other{{count} sites with coordinates have an empty country, region, town or body of water.}} Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about {minutes, plural, =1{1 minute} other{{minutes} minutes}}.'** String diveSites_backfill_confirm_body(int count, int minutes); /// No description provided for @diveSites_backfill_confirm_start. diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 75e338603f..406c88e6e7 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8637,7 +8637,14 @@ class AppLocalizationsAr extends AppLocalizations { one: 'موقع غوص واحد له إحداثيات ينقصه البلد أو المنطقة أو البلدة أو المسطح المائي.', ); - return '$_temp0 سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو $minutes دقائق.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes دقائق', + two: 'دقيقتين', + one: 'دقيقة واحدة', + ); + return '$_temp0 سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 558baedc37..bd49393c42 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8798,7 +8798,13 @@ class AppLocalizationsDe extends AppLocalizations { one: '1 Tauchplatz mit Koordinaten hat kein Land, keine Region, keinen Ort oder kein Gewässer.', ); - return '$_temp0 Submersion sucht jeden auf OpenStreetMap und füllt nur die leeren Felder aus. Das dauert etwa $minutes Minuten.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes Minuten', + one: '1 Minute', + ); + return '$_temp0 Submersion sucht jeden auf OpenStreetMap und füllt nur die leeren Felder aus. Das dauert etwa $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index acd01fa6b6..4d76e1831e 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8654,7 +8654,13 @@ class AppLocalizationsEn extends AppLocalizations { one: '1 site with coordinates has an empty country, region, town or body of water.', ); - return '$_temp0 Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about $minutes minutes.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return '$_temp0 Submersion will look each one up on OpenStreetMap and fill only the empty fields. This takes about $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 28a91eb844..8b832fcd09 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8806,7 +8806,13 @@ class AppLocalizationsEs extends AppLocalizations { one: '1 punto de buceo con coordenadas no tiene país, región, localidad o masa de agua.', ); - return '$_temp0 Submersion consultará cada uno en OpenStreetMap y rellenará solo los campos vacíos. Tarda unos $minutes minutos.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'unos $minutes minutos', + one: 'alrededor de 1 minuto', + ); + return '$_temp0 Submersion consultará cada uno en OpenStreetMap y rellenará solo los campos vacíos. Tarda $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index a6ebcdd93a..ed8f9bf128 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8838,7 +8838,13 @@ class AppLocalizationsFr extends AppLocalizations { one: '1 site avec coordonnées n\'a pas de pays, de région, de ville ou de plan d\'eau.', ); - return '$_temp0 Submersion recherchera chacun sur OpenStreetMap et ne remplira que les champs vides. Cela prend environ $minutes minutes.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutes', + one: '1 minute', + ); + return '$_temp0 Submersion recherchera chacun sur OpenStreetMap et ne remplira que les champs vides. Cela prend environ $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 74ed883bba..2cff3cf64f 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8585,7 +8585,13 @@ class AppLocalizationsHe extends AppLocalizations { other: 'ל-$count אתרים עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.', one: 'לאתר אחד עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.', ); - return '$_temp0 Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך כ-$minutes דקות.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'כ-$minutes דקות', + one: 'כדקה', + ); + return '$_temp0 Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 0fbd3bf965..0f24bb2655 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8805,7 +8805,13 @@ class AppLocalizationsIt extends AppLocalizations { one: '1 sito con coordinate non ha paese, regione, città o specchio d\'acqua.', ); - return '$_temp0 Submersion cercherà ciascuno su OpenStreetMap e compilerà solo i campi vuoti. Richiede circa $minutes minuti.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minuti', + one: '1 minuto', + ); + return '$_temp0 Submersion cercherà ciascuno su OpenStreetMap e compilerà solo i campi vuoti. Richiede circa $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 333808085f..40ac03e02d 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8735,7 +8735,13 @@ class AppLocalizationsNl extends AppLocalizations { one: '1 duikstek met coördinaten heeft geen land, regio, plaats of water.', ); - return '$_temp0 Submersion zoekt elke stek op via OpenStreetMap en vult alleen lege velden in. Dit duurt ongeveer $minutes minuten.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minuten', + one: '1 minuut', + ); + return '$_temp0 Submersion zoekt elke stek op via OpenStreetMap en vult alleen lege velden in. Dit duurt ongeveer $_temp1.'; } @override diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 868ffe2006..e10a24eb38 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8808,7 +8808,13 @@ class AppLocalizationsPt extends AppLocalizations { one: '1 local com coordenadas não tem país, região, cidade ou corpo de água.', ); - return '$_temp0 O Submersion consultará cada um no OpenStreetMap e preencherá apenas os campos vazios. Demora cerca de $minutes minutos.'; + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes minutos', + one: '1 minuto', + ); + return '$_temp0 O Submersion consultará cada um no OpenStreetMap e preencherá apenas os campos vazios. Demora cerca de $_temp1.'; } @override diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 0e1b2cbffc..8722063623 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "Duikstekken selecteren", "diveSites_list_menu_fillLocationDetails": "Ontbrekende locatiegegevens aanvullen", "diveSites_backfill_confirm_title": "Ontbrekende locatiegegevens aanvullen?", - "diveSites_backfill_confirm_body": "{count, plural, =1{1 duikstek met coördinaten heeft geen land, regio, plaats of water.} other{{count} duikstekken met coördinaten hebben geen land, regio, plaats of water.}} Submersion zoekt elke stek op via OpenStreetMap en vult alleen lege velden in. Dit duurt ongeveer {minutes} minuten.", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 duikstek met coördinaten heeft geen land, regio, plaats of water.} other{{count} duikstekken met coördinaten hebben geen land, regio, plaats of water.}} Submersion zoekt elke stek op via OpenStreetMap en vult alleen lege velden in. Dit duurt ongeveer {minutes, plural, =1{1 minuut} other{{minutes} minuten}}.", "diveSites_backfill_confirm_start": "Starten", "diveSites_backfill_nothingToFill": "Elke duikstek met coördinaten heeft al locatiegegevens.", "diveSites_backfill_progress_title": "Locatiegegevens aanvullen", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index df586da5f6..97e0baf8fc 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -11,7 +11,7 @@ "diveSites_list_menu_select": "Selecionar pontos", "diveSites_list_menu_fillLocationDetails": "Preencher detalhes de localização em falta", "diveSites_backfill_confirm_title": "Preencher os detalhes de localização em falta?", - "diveSites_backfill_confirm_body": "{count, plural, =1{1 local com coordenadas não tem país, região, cidade ou corpo de água.} other{{count} locais com coordenadas não têm país, região, cidade ou corpo de água.}} O Submersion consultará cada um no OpenStreetMap e preencherá apenas os campos vazios. Demora cerca de {minutes} minutos.", + "diveSites_backfill_confirm_body": "{count, plural, =1{1 local com coordenadas não tem país, região, cidade ou corpo de água.} other{{count} locais com coordenadas não têm país, região, cidade ou corpo de água.}} O Submersion consultará cada um no OpenStreetMap e preencherá apenas os campos vazios. Demora cerca de {minutes, plural, =1{1 minuto} other{{minutes} minutos}}.", "diveSites_backfill_confirm_start": "Iniciar", "diveSites_backfill_nothingToFill": "Todos os locais com coordenadas já têm os seus detalhes de localização.", "diveSites_backfill_progress_title": "A preencher detalhes de localização", diff --git a/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart b/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart index 08ae603535..5dd9c84add 100644 --- a/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart +++ b/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart @@ -111,6 +111,17 @@ void main() { expect(find.text('Updated 90, unchanged 13, failed 1'), findsOneWidget); }); + testWidgets('a small batch is estimated in the singular', (tester) async { + // 20 sites at two seconds each rounds up to one minute. + final notifier = _ScriptedBackfill(candidates: 20, script: const []); + await tester.pumpWidget(host(notifier)); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + + expect(find.textContaining('about 1 minute.'), findsOneWidget); + expect(find.textContaining('1 minutes'), findsNothing); + }); + testWidgets('cancel asks the notifier to stop', (tester) async { // Slow steps so the progress dialog has finished animating in before // the test taps its Cancel button. From d57f66c490c9501a97b8ed8758d3501009856e3b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 14:53:19 -0400 Subject: [PATCH 107/122] fix(sync): date a pre-1973 column instead of printing its raw epoch Review catch. _isTimestamp compared the signed value against the millis floor, so only moments after ~1973 were recognized. A moment before 1970 is a negative count and fell through to the raw-value path, printing -144720000000 where a date belonged. These are not hypothetical here. The clock-offset detector exists precisely to flag dives "dated before 1950", so a bad import or a mis-set computer clock produces exactly the negative epochs this dialog is then asked to resolve. Compare the absolute magnitude instead. The defense the floor provides is unchanged: bottomTime and runtime are handled by the duration branch before this is reached, and any other small value under a time-ish name still renders as the number it is, negative ones included, which the test now pins as the boundary the change introduces. --- .../presentation/widgets/conflict_data_preview.dart | 10 ++++++++-- .../widgets/conflict_scalar_format_test.dart | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/features/settings/presentation/widgets/conflict_data_preview.dart b/lib/features/settings/presentation/widgets/conflict_data_preview.dart index e255de10a8..6a671b3f2f 100644 --- a/lib/features/settings/presentation/widgets/conflict_data_preview.dart +++ b/lib/features/settings/presentation/widgets/conflict_data_preview.dart @@ -271,11 +271,17 @@ String _formatSeconds(int seconds) { /// True for a column that stores a moment rather than a duration. Both the /// name and the magnitude must agree: `bottomTime` and `runtime` are seconds, /// so only values large enough to be Unix millis are treated as dates. +/// +/// The magnitude is absolute, because a moment before 1970 is a negative +/// count. Those are real here rather than hypothetical: the clock-offset +/// detector exists to flag dives "dated before 1950", so the epochs most +/// likely to reach this dialog after a bad import are exactly the negative +/// ones. Comparing the signed value would print them raw. bool _isTimestamp(String key, int value) { - const millisFloor = 100000000000; // ~1973 in Unix millis + const millisFloor = 100000000000; // ~1973 either side of the epoch final named = key.endsWith('At') || key.endsWith('Time') || key.endsWith('Date'); - return named && value >= millisFloor; + return named && value.abs() >= millisFloor; } bool _usable(Map data, Set hidden, String key) => diff --git a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart index fce628d853..d03ca72636 100644 --- a/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart +++ b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart @@ -87,6 +87,15 @@ void main() { expect(formatted, contains('2026')); }); + test('dates a pre-1973 column, whose epoch millis are negative', () { + // The app models these: the clock-offset detector warns about dives + // "dated before 1950", so a negative diveDateTime is a real value and + // not a corruption to be printed raw. + final formatted = format(units, 'diveDateTime', -144720000000); + expect(formatted, isNot(contains('-144720000000'))); + expect(formatted, contains('1965')); + }); + test('leaves a time-named column too small to be Unix millis alone', () { // Every time-named column in today's schema really is an epoch value, // so this rule is defensive: it makes the formatter fail safe. A future @@ -94,6 +103,8 @@ void main() { // number it is rather than being dated to 1970. expect(format(units, 'surfaceIntervalTime', 300), '300'); expect(format(units, 'holdTime', 90), '90'); + // Small and negative, the boundary the absolute-magnitude check adds. + expect(format(units, 'holdTime', -90), '-90'); }); }); } From 21128bf5d6841708d3767da5fafa29263bf68f37 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 14:55:49 -0400 Subject: [PATCH 108/122] test(sac): forward unexpected errors in the SAC row tests; cover the download loader end to end Review follow-up on #1298. The SAC row tests swallowed every FlutterError to get past the profile chart's overflow in an unconstrained viewport, which would also have hidden a real rendering or navigation failure under the hint tap. They now ignore overflow only and forward the rest. Also drives a download through diveImportServiceProvider against the in-memory database (toggle on fills 11.1 L, toggle off leaves the tank sizeless) and taps the SAC-by-segment card's hint into the dive editor, closing the last two uncovered lines. --- .../download_default_tank_preset_test.dart | 82 ++++++++++++++++++- .../pages/dive_detail_sac_row_test.dart | 23 ++++-- .../dive_detail_sac_segments_hint_test.dart | 77 +++++++++++++++++ 3 files changed, 172 insertions(+), 10 deletions(-) diff --git a/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart b/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart index 4191d3a17f..a432b8d2be 100644 --- a/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart +++ b/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart @@ -1,6 +1,9 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/database/database.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; import 'package:submersion/features/dive_computer/presentation/providers/download_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; @@ -13,11 +16,12 @@ import '../../../../helpers/test_database.dart'; /// downloaded cylinders with, or null when the diver has not opted in. void main() { late SharedPreferences prefs; + late AppDatabase db; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); - await setUpTestDatabase(); + db = await setUpTestDatabase(); }); tearDown(() async { @@ -74,4 +78,80 @@ void main() { expect(preset, isNull); }); + + group('end to end through diveImportServiceProvider', () { + // A transmitter-equipped back gas: pressures, no size, as every dive + // computer reports it. + DownloadedDive downloadedDive() => DownloadedDive( + fingerprint: 'fp-e2e', + startTime: DateTime(2026, 4, 1, 10, 0), + durationSeconds: 2700, + maxDepth: 18.0, + profile: const [], + tanks: const [ + DownloadedTank( + index: 0, + o2Percent: 21.0, + startPressure: 200.0, + endPressure: 60.0, + role: 'backGas', + ), + ], + events: const [], + ); + + Future importedVolume(AppSettings settings) async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion( + id: const Value('computer-1'), + name: const Value('Perdix'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + final container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + settingsProvider.overrideWith( + (ref) => MockSettingsNotifier(settings), + ), + ], + ); + addTearDown(container.dispose); + + final diveId = await container + .read(diveImportServiceProvider) + .importSingleDiveAsNew(downloadedDive(), computerId: 'computer-1'); + + final tank = await (db.select( + db.diveTanks, + )..where((t) => t.diveId.equals(diveId))).getSingle(); + return tank.volume; + } + + test('a download gains the default cylinder size when opted in', () async { + final volume = await importedVolume( + const AppSettings( + applyDefaultTankToImports: true, + defaultTankPreset: 'al80', + ), + ); + + expect(volume, 11.1); + }); + + test('a download stays sizeless while the toggle is off', () async { + final volume = await importedVolume( + const AppSettings( + applyDefaultTankToImports: false, + defaultTankPreset: 'al80', + ), + ); + + expect(volume, isNull); + }); + }); } diff --git a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart index e96ba35e8a..6ed5ba5149 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_row_test.dart @@ -44,6 +44,18 @@ void main() { ); } + /// The detail page renders a profile chart that can overflow an + /// unconstrained test viewport. Ignore only that, and forward everything + /// else, so a real rendering or navigation failure still fails the test. + void ignoreOverflowErrors() { + final originalOnError = FlutterError.onError; + addTearDown(() => FlutterError.onError = originalOnError); + FlutterError.onError = (details) { + if (details.toString().contains('overflowed')) return; + originalOnError?.call(details); + }; + } + Future pumpWith( WidgetTester tester, AppSettings settings, { @@ -73,12 +85,7 @@ void main() { ), ); - // The detail page renders a profile chart that can overflow an - // unconstrained test viewport; swallow layout errors so this stays - // scoped to the SAC row, matching the sibling detail-page tests. - final originalOnError = FlutterError.onError; - FlutterError.onError = (_) {}; - addTearDown(() => FlutterError.onError = originalOnError); + ignoreOverflowErrors(); await tester.pump(); await tester.pump(const Duration(seconds: 1)); } @@ -219,9 +226,7 @@ void main() { ), ], ); - final originalOnError = FlutterError.onError; - FlutterError.onError = (_) {}; - addTearDown(() => FlutterError.onError = originalOnError); + ignoreOverflowErrors(); await tester.pumpWidget( ProviderScope( diff --git a/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart b/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart index 18fc177fb4..2a953f6b7c 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/units.dart'; import 'package:submersion/core/providers/provider.dart'; @@ -202,6 +203,82 @@ void main() { expect(hintIn(sacCard(tester)), findsNothing); }); + testWidgets('tapping the card\'s hint opens the dive editor', (tester) async { + final dive = diveWithProfile(tanks: const [backGasNoVolume]); + final base = await getBaseOverrides( + settingsNotifier: MockSettingsNotifier( + const AppSettings(sacUnit: SacUnit.litersPerMin), + ), + ); + final router = GoRouter( + initialLocation: '/test', + routes: [ + GoRoute( + path: '/test', + builder: (context, state) => + DiveDetailPage(diveId: dive.id, embedded: true), + ), + GoRoute( + path: '/dives/:id/edit', + builder: (context, state) => + Scaffold(body: Text('EDIT_STUB ${state.pathParameters['id']}')), + ), + ], + ); + final originalOnError = FlutterError.onError; + addTearDown(() => FlutterError.onError = originalOnError); + FlutterError.onError = (d) { + if (d.toString().contains('overflowed')) return; + originalOnError?.call(d); + }; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...base, + diveProvider(dive.id).overrideWith((ref) async => dive), + diveDataSourcesProvider( + dive.id, + ).overrideWith((ref) async => []), + profileAnalysisProvider( + dive.id, + ).overrideWith((ref) async => analysisWithSacSegments()), + selectedSegmentationProvider.overrideWith( + (ref) => SacSegmentationType.timeInterval, + ), + gasSwitchesProvider( + dive.id, + ).overrideWith((ref) async => []), + tankPressuresProvider( + dive.id, + ).overrideWith((ref) async => >{}), + sourceProfilesProvider( + dive.id, + ).overrideWith((ref) async => {}), + weeklyOtuProvider(dive.id).overrideWith((ref) async => 0.0), + ], + child: MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + // The Details row has its own hint; scope to the card's. + final hint = hintIn(sacCard(tester)); + expect(hint, findsOneWidget); + await tester.ensureVisible(hint); + await tester.pump(); + await tester.tap(hint); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('EDIT_STUB ${dive.id}'), findsOneWidget); + }); + testWidgets('does not borrow a stage bottle\'s volume for the back gas', ( tester, ) async { From 19dfd0583a1a472eb8d7fb109f896562e63d9ef1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 15:57:41 -0400 Subject: [PATCH 109/122] fix(dive-detail): stop a header's trailing label from starving its title Both collapsible section headers laid out `trailing` inflexibly, so RenderFlex gave it its full natural width before the Expanded title got anything. On a full-width card there was always room to spare and nobody noticed. Halve the card and there is not: the Tide header's cycle range measured 384px against a 436px row, the title collapsed to zero width and rendered "Tide" as a column of single letters, and the row still overflowed. Wrap the trailing widget in Flexible so it can only claim half the free space, and in an Align so it stays flush right whenever it fits inside that cap. A full-width card lays out byte-for-byte as before; only a card too narrow for its own header behaves differently, and there the title survives. No test of its own: the half-width case this guards only exists once cards are paired side by side, which the following commit adds along with the tests that exercise it. --- .../widgets/collapsible_section.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/features/dive_log/presentation/widgets/collapsible_section.dart b/lib/features/dive_log/presentation/widgets/collapsible_section.dart index 8f88449c82..2ba29a2173 100644 --- a/lib/features/dive_log/presentation/widgets/collapsible_section.dart +++ b/lib/features/dive_log/presentation/widgets/collapsible_section.dart @@ -2,6 +2,21 @@ import 'package:flutter/material.dart'; import 'package:submersion/l10n/l10n_extension.dart'; +/// Wraps a header's trailing widget so it yields space instead of starving the +/// title. +/// +/// Laid out plainly, a trailing widget is inflexible: [Row] gives it its full +/// natural width first and leaves the title whatever remains, which can be +/// nothing at all once a card is only half a pane wide (the side-by-side +/// section pairs on the dive detail page). Making it [Flexible] caps it at +/// half the free space; the [Align] keeps it flush right whenever it fits +/// inside that cap, so nothing moves on a full-width card. +Widget _flexibleTrailing(Widget trailing) { + return Flexible( + child: Align(alignment: Alignment.centerRight, child: trailing), + ); +} + /// A collapsible section widget that wraps content with an expandable header. /// /// When collapsed, shows only the header bar. When expanded, shows the @@ -94,7 +109,7 @@ class CollapsibleSection extends StatelessWidget { ), ), if (trailing != null && !isExpanded) ...[ - trailing!, + _flexibleTrailing(trailing!), const SizedBox(width: 8), ], AnimatedRotation( @@ -226,7 +241,7 @@ class CollapsibleCardSection extends StatelessWidget { ], // Always show the trailing widget if (trailing != null) ...[ - trailing!, + _flexibleTrailing(trailing!), const SizedBox(width: 8), ], AnimatedRotation( From d76f54b7c143819fe58d0b0152f01f4094a06686 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 15:58:03 -0400 Subject: [PATCH 110/122] feat(dive-detail): pair Surface GPS with Tide, Cylinders with Weights Details and Environment already sit side by side on a wide pane. Do the same for two more pairs that read as one thought: the surface fixes next to the tide they were taken on, and the cylinders next to the lead that offsets them. The existing rule could not express either pair. It required the two halves to be immediately adjacent in the diver's configured section order, and neither pair was: Water Conditions sat between Tide and Surface GPS, and Buoyancy between Weights and Cylinders. Buoyancy is the harder case, because it renders exactly when the dive has cylinders -- the condition for a Cylinders card to exist at all. Under adjacency the pair was unreachable, not merely unlucky. So pairing now looks ahead. A pair forms whenever both halves are visible and both have content, wherever they sit; the row renders at the slot of whichever half comes first and the section between them drops below. Left and right come from the pair table rather than the configured order, so a diver whose saved order predates a pair still gets Surface GPS and Cylinders on the left instead of a mirrored layout. When either half has nothing to show, both render full-width in their own slots exactly as before. The lookahead replaces adjacency for all four pairs rather than only the new ones. Two pairing rules in one loop is a trap for whoever adds the fifth pair, and the case adjacency protected -- a diver who deliberately parked a section between Details and Environment to break the pair -- is indistinguishable from a diver who simply never touched the old default order. The pairs themselves move into a const table so a fifth is a one-line addition rather than another branch in a 5,477-line page, and the default order is reshuffled to list each pair's halves together. Existing saved orders are untouched and pair anyway, which is the whole point of the lookahead. Splitting the tide card out of its section wrapper gives the pair a bare card to place and a null to gate on from one code path, so the section and the pair cannot disagree about whether there is tide data to show. --- .../constants/dive_detail_section_pairs.dart | 53 +++ lib/core/constants/dive_detail_sections.dart | 12 +- .../presentation/pages/dive_detail_page.dart | 290 +++++++----- .../dive_detail_section_pairs_test.dart | 80 ++++ ...dive_detail_page_paired_sections_test.dart | 423 ++++++++++++++++-- 5 files changed, 715 insertions(+), 143 deletions(-) create mode 100644 lib/core/constants/dive_detail_section_pairs.dart create mode 100644 test/core/constants/dive_detail_section_pairs_test.dart diff --git a/lib/core/constants/dive_detail_section_pairs.dart b/lib/core/constants/dive_detail_section_pairs.dart new file mode 100644 index 0000000000..aaee40e664 --- /dev/null +++ b/lib/core/constants/dive_detail_section_pairs.dart @@ -0,0 +1,53 @@ +import 'package:submersion/core/constants/dive_detail_sections.dart'; + +/// Two dive-detail sections whose cards render side by side when the detail +/// pane is wide enough. +/// +/// [left] and [right] fix the on-screen arrangement independently of where +/// each section sits in the diver's configured order, so a saved order that +/// predates the pair still lays out the intended way. +class DiveDetailSectionPair { + const DiveDetailSectionPair(this.left, this.right); + + /// The section shown in the left column (top card when stacked). + final DiveDetailSectionId left; + + /// The section shown in the right column (bottom card when stacked). + final DiveDetailSectionId right; + + /// The other half of the pair, or null when [id] is not part of it. + DiveDetailSectionId? partnerOf(DiveDetailSectionId id) { + if (id == left) return right; + if (id == right) return left; + return null; + } +} + +/// Every dive-detail card pair, in left-then-right order. +/// +/// A section belongs to at most one pair; [diveDetailSectionPairFor] relies on +/// that. The default section order in [DiveDetailSectionId] lists each pair's +/// halves adjacently and in this same order. +const List kDiveDetailSectionPairs = [ + DiveDetailSectionPair( + DiveDetailSectionId.details, + DiveDetailSectionId.environment, + ), + DiveDetailSectionPair( + DiveDetailSectionId.surfaceGps, + DiveDetailSectionId.tide, + ), + DiveDetailSectionPair(DiveDetailSectionId.tanks, DiveDetailSectionId.weights), + DiveDetailSectionPair( + DiveDetailSectionId.buddies, + DiveDetailSectionId.signatures, + ), +]; + +/// The pair [id] belongs to, or null when the section never pairs. +DiveDetailSectionPair? diveDetailSectionPairFor(DiveDetailSectionId id) { + for (final pair in kDiveDetailSectionPairs) { + if (pair.partnerOf(id) != null) return pair; + } + return null; +} diff --git a/lib/core/constants/dive_detail_sections.dart b/lib/core/constants/dive_detail_sections.dart index e0d81fe557..7fec4ebb80 100644 --- a/lib/core/constants/dive_detail_sections.dart +++ b/lib/core/constants/dive_detail_sections.dart @@ -6,6 +6,10 @@ import 'package:submersion/l10n/arb/app_localizations.dart'; /// /// Declaration order defines the default display order. The two fixed sections /// (Header and Dive Profile Chart) are not included — they always render first. +/// +/// Sections that pair side by side on a wide pane are declared adjacently, in +/// left-then-right order (see `kDiveDetailSectionPairs`), so the default order +/// already reads the way the paired layout renders. enum DiveDetailSectionId { decoO2, safetyReview, @@ -13,12 +17,12 @@ enum DiveDetailSectionId { details, environment, altitude, + surfaceGps, tide, reefHealth, - surfaceGps, + tanks, weights, buoyancy, - tanks, buddies, signatures, equipment, @@ -186,12 +190,12 @@ class DiveDetailSectionConfig { DiveDetailSectionConfig(id: DiveDetailSectionId.details, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.environment, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.altitude, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.surfaceGps, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.tide, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.reefHealth, visible: true), - DiveDetailSectionConfig(id: DiveDetailSectionId.surfaceGps, visible: true), + DiveDetailSectionConfig(id: DiveDetailSectionId.tanks, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.weights, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.buoyancy, visible: true), - DiveDetailSectionConfig(id: DiveDetailSectionId.tanks, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.buddies, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.signatures, visible: true), DiveDetailSectionConfig(id: DiveDetailSectionId.equipment, visible: true), diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 5fe333c499..6857e2f438 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart' show DateFormat; import 'package:latlong2/latlong.dart'; import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/core/constants/dive_detail_section_pairs.dart'; import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/features/equipment/presentation/utils/equipment_type_icon.dart'; @@ -418,28 +419,10 @@ class _DiveDetailPageState extends ConsumerState { return [_buildReefHealthSection(context, ref, dive)]; }, DiveDetailSectionId.surfaceGps: () { - if (dive.entryLocation == null && dive.exitLocation == null) return []; + if (!_hasSurfaceGps(dive)) return []; return [ const SizedBox(height: 24), - Consumer( - builder: (context, ref, _) { - final viewedSourceId = ref.watch( - activeDiveSourceProvider(dive.id), - ); - final dataSources = computerReadingsAsync.valueOrNull ?? []; - final attribution = FieldAttributionService.computeAttribution( - dataSources, - viewedSourceId: viewedSourceId, - nameOf: (s) => resolveSourceName(s, _sourceNameLabels(context)), - ); - final showBadges = - settings.showDataSourceBadges && attribution.isNotEmpty; - return SurfaceGpsSection( - dive: dive, - sourceName: showBadges ? attribution['gps'] : null, - ); - }, - ), + _surfaceGpsCard(dive, computerReadingsAsync, settings), ]; }, DiveDetailSectionId.weights: () { @@ -460,12 +443,7 @@ class _DiveDetailPageState extends ConsumerState { if (dive.tanks.isEmpty) return []; return [ const SizedBox(height: 24), - CylindersCard( - dive: dive, - units: units, - settings: settings, - sacUnit: ref.watch(sacUnitProvider), - ), + _cylindersCard(dive, units, settings), ]; }, DiveDetailSectionId.buddies: () { @@ -556,6 +534,48 @@ class _DiveDetailPageState extends ConsumerState { }; } + /// Whether the dive has a surface GPS fix to map. + bool _hasSurfaceGps(Dive dive) => + dive.entryLocation != null || dive.exitLocation != null; + + /// The Surface GPS card, including its data-source attribution [Consumer]. + /// Extracted so both the normal section flow and the side-by-side pairing + /// (with Tide) render identical content. + Widget _surfaceGpsCard( + Dive dive, + AsyncValue> computerReadingsAsync, + AppSettings settings, + ) { + return Consumer( + builder: (context, ref, _) { + final viewedSourceId = ref.watch(activeDiveSourceProvider(dive.id)); + final dataSources = computerReadingsAsync.valueOrNull ?? []; + final attribution = FieldAttributionService.computeAttribution( + dataSources, + viewedSourceId: viewedSourceId, + nameOf: (s) => resolveSourceName(s, _sourceNameLabels(context)), + ); + final showBadges = + settings.showDataSourceBadges && attribution.isNotEmpty; + return SurfaceGpsSection( + dive: dive, + sourceName: showBadges ? attribution['gps'] : null, + ); + }, + ); + } + + /// The Cylinders card. Extracted so both the normal section flow and the + /// side-by-side pairing (with Weights) render identical content. + Widget _cylindersCard(Dive dive, UnitFormatter units, AppSettings settings) { + return CylindersCard( + dive: dive, + units: units, + settings: settings, + sacUnit: ref.watch(sacUnitProvider), + ); + } + /// The Details card, including its data-source attribution [Consumer]. /// Extracted so both the normal section flow and the side-by-side pairing /// (with Conditions) render identical content. @@ -609,13 +629,21 @@ class _DiveDetailPageState extends ConsumerState { ); } - /// Builds the ordered configurable-section widgets, pairing two specific - /// adjacent card pairs side by side when the pane is wide enough: - /// Details + Conditions, and Buddies + Signatures. + /// Builds the ordered configurable-section widgets, rendering the fixed card + /// pairs in [kDiveDetailSectionPairs] side by side when the pane is wide + /// enough: Details + Conditions, Surface GPS + Tide, Cylinders + Weights, + /// and Buddies + Signatures. + /// + /// A pair forms whenever both halves are visible and both have content to + /// show, wherever they sit in the configured order. The row renders at the + /// slot of whichever half comes first and anything between them drops below, + /// which is what lets a diver whose saved order predates a pair (Water + /// Conditions between Tide and Surface GPS, Buoyancy between Weights and + /// Cylinders) still get the paired layout. Left/right come from the pair + /// definition rather than the configured order, so the arrangement is the + /// same either way. When either half has nothing to show, both render + /// full-width in their own slots exactly as before. /// - /// Pairing is fixed-pairs and adjacency-gated: the two must be immediately - /// adjacent in the configured (visible) order and the second must have - /// content, otherwise each section renders full-width exactly as before. /// [ResponsiveSectionPair] then decides row-vs-stacked from its own measured /// width, so narrow panes stay stacked and unchanged. List _buildOrderedSections({ @@ -633,54 +661,37 @@ class _DiveDetailPageState extends ConsumerState { if (section.visible && !(dive.isGauge && section.id.hiddenInGaugeMode)) section.id, ]; - - // Conditions self-suppresses when empty (cheap, no provider). The - // Signatures presence gate needs buddiesForDiveProvider, so it is read - // lazily inside the Buddies+Signatures branch below -- only when that pair - // is actually adjacent -- to avoid coupling the whole page to buddy - // changes when the pair can never form. - final hasConditions = _hasEnvironmentData(dive); + final visibleIds = visible.toSet(); final children = []; - for (var i = 0; i < visible.length; i++) { - final id = visible[i]; - final next = i + 1 < visible.length ? visible[i + 1] : null; - - if (id == DiveDetailSectionId.details && - next == DiveDetailSectionId.environment && - hasConditions) { - // Details has no leading spacer today; the pair keeps that. - children.add( - ResponsiveSectionPair( - first: _detailsCard( - context, - dive, - units, - computerReadingsAsync, - settings, - ), - second: _buildEnvironmentSection(context, dive, units), - ), - ); - i++; - continue; - } + final consumed = {}; - if (id == DiveDetailSectionId.buddies && - next == DiveDetailSectionId.signatures) { - // Signatures self-erases unless the dive has buddies or a course. - final buddies = - ref.watch(buddiesForDiveProvider(dive.id)).valueOrNull ?? - const []; - if (buddies.isNotEmpty || dive.courseId != null) { - children.add(const SizedBox(height: 24)); // Buddies' leading gap. + for (final id in visible) { + // The trailing half of a pair already rendered at the leading half's + // slot. + if (consumed.contains(id)) continue; + + final pair = diveDetailSectionPairFor(id); + if (pair != null && visibleIds.contains(pair.partnerOf(id)!)) { + final cards = _buildPairCards( + pair, + context: context, + ref: ref, + dive: dive, + units: units, + computerReadingsAsync: computerReadingsAsync, + settings: settings, + ); + if (cards != null) { + // Details is the one section that emits no leading gap of its own + // (it butts against the profile chart); the pair keeps that. + if (pair.left != DiveDetailSectionId.details) { + children.add(const SizedBox(height: 24)); + } children.add( - ResponsiveSectionPair( - first: _buildBuddiesSection(context, ref, dive), - second: _signaturesColumn(context, ref, dive), - ), + ResponsiveSectionPair(first: cards.$1, second: cards.$2), ); - i++; + consumed.addAll([pair.left, pair.right]); continue; } } @@ -690,6 +701,62 @@ class _DiveDetailPageState extends ConsumerState { return children; } + /// The two bare cards for [pair] in left-then-right order, or null when + /// either half has nothing to show -- in which case both sections fall back + /// to rendering full-width in their own slots. + /// + /// The presence gates mirror what each section builder would decide for + /// itself: Conditions, Tide and Signatures all self-erase when empty, so + /// without these checks a pair could put a blank column beside a half-width + /// card. + (Widget, Widget)? _buildPairCards( + DiveDetailSectionPair pair, { + required BuildContext context, + required WidgetRef ref, + required Dive dive, + required UnitFormatter units, + required AsyncValue> computerReadingsAsync, + required AppSettings settings, + }) { + switch (pair.left) { + case DiveDetailSectionId.details: + if (!_hasEnvironmentData(dive)) return null; + return ( + _detailsCard(context, dive, units, computerReadingsAsync, settings), + _buildEnvironmentSection(context, dive, units), + ); + + case DiveDetailSectionId.surfaceGps: + if (!_hasSurfaceGps(dive)) return null; + final tide = _tideCard(context, ref, dive); + if (tide == null) return null; + return (_surfaceGpsCard(dive, computerReadingsAsync, settings), tide); + + case DiveDetailSectionId.tanks: + if (dive.tanks.isEmpty || !_hasWeights(dive)) return null; + return ( + _cylindersCard(dive, units, settings), + _buildWeightSection(context, dive, units), + ); + + case DiveDetailSectionId.buddies: + // Signatures self-erases unless the dive has buddies or a course. The + // read is deferred to here so the page only couples to buddy changes + // when the pair can actually form. + final buddies = + ref.watch(buddiesForDiveProvider(dive.id)).valueOrNull ?? + const []; + if (buddies.isEmpty && dive.courseId == null) return null; + return ( + _buildBuddiesSection(context, ref, dive), + _signaturesColumn(context, ref, dive), + ); + + default: + return null; + } + } + /// Overflow entry for the pre-dive checklist link (#1066). PR #913 removed /// the dive-detail pre-dive card, and with it the only way to attach a run /// completed the evening before -- outside the auto-linker's three-hour @@ -3670,18 +3737,24 @@ class _DiveDetailPageState extends ConsumerState { /// takes up zero space. The 24-px top spacer is included only when the /// section actually renders content. Widget _buildTideSection(BuildContext context, WidgetRef ref, Dive dive) { + final card = _tideCard(context, ref, dive); + if (card == null) return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [const SizedBox(height: 24), card], + ); + } + + /// The tide card without its leading section gap, or null when the dive has + /// no tide data to show. + /// + /// Extracted from [_buildTideSection] so the side-by-side pairing (with + /// Surface GPS) can use the same null result both as the presence gate and + /// as the bare card to place in the row. + Widget? _tideCard(BuildContext context, WidgetRef ref, Dive dive) { // Freshwater sites have no tides; hide the section entirely, even // when an old stored record exists. - if (dive.site?.waterType == WaterType.fresh) { - return const SizedBox.shrink(); - } - - Widget withSpacing(Widget card) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [const SizedBox(height: 24), card], - ); - } + if (dive.site?.waterType == WaterType.fresh) return null; // First try to get stored tide record (lazily self-healed against a // fresh computation when the site has coordinates) @@ -3693,32 +3766,26 @@ class _DiveDetailPageState extends ConsumerState { )), ); - return tideRecordAsync.when( + return tideRecordAsync.when( data: (tideRecord) { if (tideRecord != null) { - return withSpacing( - _buildTideCard( - context, - tideRecord, - entryTime: dive.effectiveEntryTime, - ), + return _buildTideCard( + context, + tideRecord, + entryTime: dive.effectiveEntryTime, ); } // No stored record - try to calculate from tide model if we have coordinates - if (dive.site?.hasCoordinates != true) { - return const SizedBox.shrink(); - } + if (dive.site?.hasCoordinates != true) return null; final location = dive.site!.location!; final entryTime = dive.effectiveEntryTime; final calculatorAsync = ref.watch(tideCalculatorProvider(location)); - return calculatorAsync.when( + return calculatorAsync.when( data: (calculator) { - if (calculator == null) { - return const SizedBox.shrink(); // No tide data for this location - } + if (calculator == null) return null; // No tide data here. final status = calculator.getStatus(entryTime); final record = TideRecord.fromStatus( @@ -3727,21 +3794,19 @@ class _DiveDetailPageState extends ConsumerState { status: status, ); - return withSpacing( - _buildTideCard( - context, - record, - isCalculated: true, - entryTime: entryTime, - ), + return _buildTideCard( + context, + record, + isCalculated: true, + entryTime: entryTime, ); }, - loading: () => const SizedBox.shrink(), - error: (_, _) => const SizedBox.shrink(), + loading: () => null, + error: (_, _) => null, ); }, - loading: () => const SizedBox.shrink(), - error: (_, _) => const SizedBox.shrink(), + loading: () => null, + error: (_, _) => null, ); } @@ -3835,9 +3900,14 @@ class _DiveDetailPageState extends ConsumerState { ), ) : null, + // The cycle range is secondary to the card itself: in a half-width + // column it trims rather than wrapping the header onto three lines. trailing: isExpanded && dateTimeLabel.isNotEmpty ? Text( dateTimeLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/test/core/constants/dive_detail_section_pairs_test.dart b/test/core/constants/dive_detail_section_pairs_test.dart new file mode 100644 index 0000000000..1d9bc5382b --- /dev/null +++ b/test/core/constants/dive_detail_section_pairs_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/dive_detail_section_pairs.dart'; +import 'package:submersion/core/constants/dive_detail_sections.dart'; + +void main() { + group('DiveDetailSectionPair', () { + test('partnerOf resolves either half and rejects outsiders', () { + const pair = DiveDetailSectionPair( + DiveDetailSectionId.tanks, + DiveDetailSectionId.weights, + ); + + expect(pair.partnerOf(DiveDetailSectionId.tanks), pair.right); + expect(pair.partnerOf(DiveDetailSectionId.weights), pair.left); + expect(pair.partnerOf(DiveDetailSectionId.notes), isNull); + }); + }); + + group('kDiveDetailSectionPairs', () { + test('pairs the four card pairs, left half first', () { + expect(kDiveDetailSectionPairs.map((p) => (p.left, p.right)), [ + (DiveDetailSectionId.details, DiveDetailSectionId.environment), + (DiveDetailSectionId.surfaceGps, DiveDetailSectionId.tide), + (DiveDetailSectionId.tanks, DiveDetailSectionId.weights), + (DiveDetailSectionId.buddies, DiveDetailSectionId.signatures), + ]); + }); + + // diveDetailSectionPairFor returns the first match, so a section in two + // pairs would silently lose one of them. + test('no section appears in more than one pair', () { + final seen = {}; + for (final pair in kDiveDetailSectionPairs) { + expect(seen.add(pair.left), isTrue, reason: '${pair.left} repeats'); + expect(seen.add(pair.right), isTrue, reason: '${pair.right} repeats'); + } + }); + + // Pairing looks ahead, so a gap would still pair -- but the settings list + // reads as the page renders only while the halves sit together. + test('default order lists each pair adjacently, left half first', () { + final order = DiveDetailSectionConfig.defaultSections + .map((s) => s.id) + .toList(); + + for (final pair in kDiveDetailSectionPairs) { + expect( + order.indexOf(pair.right), + order.indexOf(pair.left) + 1, + reason: '${pair.left} should be followed by ${pair.right}', + ); + } + }); + + test('the declaration order matches the default section order', () { + expect( + DiveDetailSectionConfig.defaultSections.map((s) => s.id), + DiveDetailSectionId.values, + ); + }); + }); + + group('diveDetailSectionPairFor', () { + test('finds the pair from either half', () { + expect( + diveDetailSectionPairFor(DiveDetailSectionId.tide)?.left, + DiveDetailSectionId.surfaceGps, + ); + expect( + diveDetailSectionPairFor(DiveDetailSectionId.surfaceGps)?.right, + DiveDetailSectionId.tide, + ); + }); + + test('returns null for a section that never pairs', () { + expect(diveDetailSectionPairFor(DiveDetailSectionId.buoyancy), isNull); + expect(diveDetailSectionPairFor(DiveDetailSectionId.reefHealth), isNull); + }); + }); +} diff --git a/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart b/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart index 332b91fcdb..63dcab8347 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart @@ -6,20 +6,26 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/constants/dive_detail_sections.dart'; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/constants/map_style.dart'; +import 'package:submersion/core/tide/entities/tide_extremes.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; import 'package:submersion/features/buddies/presentation/providers/buddy_providers.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_data_source.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; import 'package:submersion/features/dive_log/presentation/pages/dive_detail_page.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_log/presentation/widgets/responsive_section_pair.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/surface_gps_section.dart'; import 'package:submersion/features/dive_roles/domain/entities/dive_role.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; import 'package:submersion/features/marine_life/domain/entities/species.dart'; import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/signatures/domain/entities/signature.dart'; import 'package:submersion/features/signatures/presentation/providers/signature_providers.dart'; +import 'package:submersion/features/tides/domain/entities/tide_record.dart'; +import 'package:submersion/features/tides/presentation/providers/tide_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; typedef Override = riverpod.Override; @@ -100,6 +106,66 @@ Dive _diveWithConditions(String id) => Dive( currentStrength: CurrentStrength.moderate, ); +/// A dive with entry/exit fixes so the Surface GPS card renders. +Dive _diveWithGps(String id) => Dive( + id: id, + dateTime: DateTime(2026, 3, 15, 10, 0), + entryLocation: const GeoPoint(12.34567, 98.76543), + exitLocation: const GeoPoint(12.34612, 98.76489), +); + +/// A dive with a cylinder and a weight so both of those cards render. +Dive _diveWithGasAndWeights(String id) => Dive( + id: id, + dateTime: DateTime(2026, 3, 15, 10, 0), + tanks: const [ + DiveTank( + id: 't1', + name: 'AL80', + volume: 11.1, + workingPressure: 207, + startPressure: 200, + endPressure: 50, + ), + ], + weights: [ + DiveWeight(id: 'w1', diveId: id, weightType: WeightType.belt, amountKg: 6), + ], +); + +TideRecord _tideRecord(String diveId) => TideRecord( + id: 'tide-1', + diveId: diveId, + heightMeters: 1.6, + tideState: TideState.rising, + rateOfChange: 0.4, + highTideHeight: 2.4, + highTideTime: DateTime.utc(2026, 3, 15, 14), + lowTideHeight: 0.4, + lowTideTime: DateTime.utc(2026, 3, 15, 8), + createdAt: DateTime.utc(2026, 3, 15, 12), +); + +/// Overrides the healed tide record for [dive]; [record] null = no tide data. +Override _tideOverride(Dive dive, TideRecord? record) => + healedTideRecordProvider(( + diveId: dive.id, + location: dive.site?.location, + entryTime: dive.effectiveEntryTime, + )).overrideWith((ref) async => record); + +/// Section config listing [order] as the visible sections, in that order. +AppSettings _settingsWithOrder(List order) { + return AppSettings( + diveDetailSections: [ + for (final id in order) DiveDetailSectionConfig(id: id, visible: true), + for (final id in DiveDetailSectionId.values) + if (!order.contains(id)) + DiveDetailSectionConfig(id: id, visible: false), + ], + ); +} + final _buddy = BuddyWithRole( buddy: Buddy( id: 'b1', @@ -206,36 +272,19 @@ void main() { expect(find.text('Environment'), findsNothing); }); - testWidgets('no pairing when the two are reordered apart', (tester) async { + testWidgets('still pairs when another section sits between them', ( + tester, + ) async { await tester.binding.setSurfaceSize(const Size(1000, 2000)); addTearDown(() => tester.binding.setSurfaceSize(null)); final dive = _diveWithConditions('reordered'); // Put another visible section between details and environment. - final settings = AppSettings( - diveDetailSections: [ - const DiveDetailSectionConfig( - id: DiveDetailSectionId.details, - visible: true, - ), - const DiveDetailSectionConfig( - id: DiveDetailSectionId.notes, - visible: true, - ), - const DiveDetailSectionConfig( - id: DiveDetailSectionId.environment, - visible: true, - ), - ...DiveDetailSectionId.values - .where( - (id) => - id != DiveDetailSectionId.details && - id != DiveDetailSectionId.notes && - id != DiveDetailSectionId.environment, - ) - .map((id) => DiveDetailSectionConfig(id: id, visible: false)), - ], - ); + final settings = _settingsWithOrder([ + DiveDetailSectionId.details, + DiveDetailSectionId.notes, + DiveDetailSectionId.environment, + ]); await tester.pumpWidget( _buildTestWidget( @@ -246,10 +295,17 @@ void main() { ); await tester.pumpAndSettle(); - // Adjacency broken => full-width, no pairing (both cards still present). - expect(find.byType(ResponsiveSectionPair), findsNothing); - expect(find.text('Details'), findsOneWidget); - expect(find.text('Environment'), findsOneWidget); + // Pairing looks ahead past Notes, so the pair still forms at Details' + // slot and Notes drops below it. + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + expect(_pairContaining('Details'), findsOneWidget); + expect(_pairContaining('Environment'), findsOneWidget); + + final detailsY = tester.getTopLeft(find.text('Details')).dy; + final envY = tester.getTopLeft(find.text('Environment')).dy; + final notesY = tester.getTopLeft(find.text('Notes')).dy; + expect((detailsY - envY).abs(), lessThan(4)); + expect(notesY, greaterThan(detailsY)); }); }); @@ -309,4 +365,313 @@ void main() { expect(find.byType(ResponsiveSectionPair), findsNothing); }); }); + + group('Surface GPS + Tide pairing', () { + testWidgets('pairs side by side on a wide pane', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGps('gps-tide-wide'); + final settings = _settingsWithOrder([ + DiveDetailSectionId.surfaceGps, + DiveDetailSectionId.tide, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: [ + ..._renderOverrides(dive.id, prefs), + _tideOverride(dive, _tideRecord(dive.id)), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + expect( + find.ancestor( + of: find.byType(SurfaceGpsSection), + matching: find.byType(ResponsiveSectionPair), + ), + findsOneWidget, + ); + expect(_pairContaining('Tide'), findsOneWidget); + + // Surface GPS sits to the left of Tide, at the same vertical offset. + final gpsPos = tester.getTopLeft(find.text('Surface GPS')); + final tidePos = tester.getTopLeft(find.text('Tide')); + expect(gpsPos.dx, lessThan(tidePos.dx)); + expect((gpsPos.dy - tidePos.dy).abs(), lessThan(4)); + }); + + testWidgets('pairs across an intervening Water Conditions section', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGps('gps-tide-gap'); + // The pre-existing default order, which every upgrading user has saved. + final settings = _settingsWithOrder([ + DiveDetailSectionId.tide, + DiveDetailSectionId.reefHealth, + DiveDetailSectionId.surfaceGps, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: [ + ..._renderOverrides(dive.id, prefs), + _tideOverride(dive, _tideRecord(dive.id)), + ], + ), + ); + await tester.pumpAndSettle(); + + // Surface GPS stays on the left even though Tide comes first in the + // saved order. + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + final gpsPos = tester.getTopLeft(find.text('Surface GPS')); + final tidePos = tester.getTopLeft(find.text('Tide')); + expect(gpsPos.dx, lessThan(tidePos.dx)); + expect((gpsPos.dy - tidePos.dy).abs(), lessThan(4)); + }); + + testWidgets('stacks on a narrow pane', (tester) async { + await tester.binding.setSurfaceSize(const Size(700, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGps('gps-tide-narrow'); + final settings = _settingsWithOrder([ + DiveDetailSectionId.surfaceGps, + DiveDetailSectionId.tide, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: [ + ..._renderOverrides(dive.id, prefs), + _tideOverride(dive, _tideRecord(dive.id)), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + final gpsY = tester.getTopLeft(find.text('Surface GPS')).dy; + final tideY = tester.getTopLeft(find.text('Tide')).dy; + expect(gpsY, lessThan(tideY)); + }); + + testWidgets('no pairing when the dive has no tide data', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGps('gps-no-tide'); + final settings = _settingsWithOrder([ + DiveDetailSectionId.surfaceGps, + DiveDetailSectionId.tide, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: [ + ..._renderOverrides(dive.id, prefs), + _tideOverride(dive, null), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsNothing); + expect(find.byType(SurfaceGpsSection), findsOneWidget); + expect(find.text('Tide'), findsNothing); + }); + + testWidgets('no pairing when the dive has no GPS fixes', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = Dive(id: 'no-gps', dateTime: DateTime(2026, 3, 15, 10, 0)); + final settings = _settingsWithOrder([ + DiveDetailSectionId.surfaceGps, + DiveDetailSectionId.tide, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: [ + ..._renderOverrides(dive.id, prefs), + _tideOverride(dive, _tideRecord(dive.id)), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsNothing); + expect(find.byType(SurfaceGpsSection), findsNothing); + expect(find.text('Tide'), findsOneWidget); + }); + }); + + group('Cylinders + Weights pairing', () { + testWidgets('pairs side by side on a wide pane', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGasAndWeights('gas-weights-wide'); + final settings = _settingsWithOrder([ + DiveDetailSectionId.tanks, + DiveDetailSectionId.weights, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: _renderOverrides(dive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + expect(_pairContaining('Cylinders'), findsOneWidget); + expect(_pairContaining('Weight'), findsOneWidget); + + final cylPos = tester.getTopLeft(find.text('Cylinders')); + final weightPos = tester.getTopLeft(find.text('Weight')); + expect(cylPos.dx, lessThan(weightPos.dx)); + expect((cylPos.dy - weightPos.dy).abs(), lessThan(4)); + }); + + testWidgets('pairs across an intervening Buoyancy section', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGasAndWeights('gas-weights-gap'); + // The pre-existing default order, which every upgrading user has saved. + final settings = _settingsWithOrder([ + DiveDetailSectionId.weights, + DiveDetailSectionId.buoyancy, + DiveDetailSectionId.tanks, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: _renderOverrides(dive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + // Cylinders stays on the left even though Weights comes first in the + // saved order. + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + final cylPos = tester.getTopLeft(find.text('Cylinders')); + final weightPos = tester.getTopLeft(find.text('Weight')); + expect(cylPos.dx, lessThan(weightPos.dx)); + expect((cylPos.dy - weightPos.dy).abs(), lessThan(4)); + }); + + testWidgets('stacks on a narrow pane', (tester) async { + await tester.binding.setSurfaceSize(const Size(700, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGasAndWeights('gas-weights-narrow'); + final settings = _settingsWithOrder([ + DiveDetailSectionId.tanks, + DiveDetailSectionId.weights, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: _renderOverrides(dive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + final cylY = tester.getTopLeft(find.text('Cylinders')).dy; + final weightY = tester.getTopLeft(find.text('Weight')).dy; + expect(cylY, lessThan(weightY)); + }); + + testWidgets('no pairing when the dive carries no weights', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = Dive( + id: 'gas-only', + dateTime: DateTime(2026, 3, 15, 10, 0), + tanks: const [ + DiveTank( + id: 't1', + name: 'AL80', + volume: 11.1, + workingPressure: 207, + startPressure: 200, + endPressure: 50, + ), + ], + ); + final settings = _settingsWithOrder([ + DiveDetailSectionId.tanks, + DiveDetailSectionId.weights, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: _renderOverrides(dive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsNothing); + expect(find.text('Cylinders'), findsOneWidget); + expect(find.text('Weight'), findsNothing); + }); + + testWidgets('no pairing on a gauge dive, where Cylinders is hidden', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1000, 3000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final dive = _diveWithGasAndWeights( + 'gauge-dive', + ).copyWith(diveMode: DiveMode.gauge); + final settings = _settingsWithOrder([ + DiveDetailSectionId.tanks, + DiveDetailSectionId.weights, + ]); + + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: settings, + extraOverrides: _renderOverrides(dive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResponsiveSectionPair), findsNothing); + expect(find.text('Cylinders'), findsNothing); + expect(find.text('Weight'), findsOneWidget); + }); + }); } From ed9302cb83664dd38889fc62238d1acbf05b0615 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 16:17:58 -0400 Subject: [PATCH 111/122] fix(sync): keep base publishes off a phantom Temp subdirectory on Windows (#1304) Switching the sync backend forces a full base publish, and on Windows every attempt died with: PathNotFoundException: Cannot copy file to '...\submersion/sync_base_publish/Temp/ssv1_base__1..json' (OS Error: Das System kann den angegebenen Pfad nicht finden, errno = 3) Two halves, neither visible on POSIX: 1. Sync assembles paths by interpolating a literal '/', so on Windows the export path mixes separators: C:\Users\x\AppData\Local\Temp/ssv1_base__1..json 2. ChangesetWriter._recordResumable took the filename with base.path.split(Platform.pathSeparator).last, which on Windows splits on '\' only. The last segment was therefore 'Temp/ssv1_base_....json', and the move target became /Temp/: a subdirectory nothing ever creates. The rename failed and the copy fallback threw uncaught. Both reconstructed strings match the reported error character for character. Extract basePublishTargetPath, which uses p.basename and p.join. Under the Windows style basename treats BOTH separators as separators, so either path shape resolves. Also switch the two path origins (exportBaseToTempFile and resolveBasePublishDir) to p.join so the mixed shape stops being produced. On macOS and Linux Platform.pathSeparator is '/', so the malformed path splits correctly by luck and the existing ChangesetWriter resume integration tests pass either way. The regression tests therefore pin p.Style.windows explicitly via the helper's injectable p.Context, which is the only reason that seam exists. No migration is needed: the copy threw before creating anything at the target, and the orphaned export sits in the OS temp dir that deleteLeftoverBaseTempFiles already sweeps. --- .../sync/changeset_log/changeset_writer.dart | 3 +- .../changeset_log/resumable_base_publish.dart | 25 +++++++- .../services/sync/sync_data_serializer.dart | 10 +++- .../resumable_base_publish_test.dart | 58 +++++++++++++++++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/lib/core/services/sync/changeset_log/changeset_writer.dart b/lib/core/services/sync/changeset_log/changeset_writer.dart index bb9fd2524c..4b6b72b68b 100644 --- a/lib/core/services/sync/changeset_log/changeset_writer.dart +++ b/lib/core/services/sync/changeset_log/changeset_writer.dart @@ -506,8 +506,7 @@ class ChangesetWriter { String? uploadNonce, ) async { final dir = await _resumable.directory; - final target = - '${dir.path}/${base.path.split(Platform.pathSeparator).last}'; + final target = basePublishTargetPath(dir.path, base.path); final source = File(base.path); try { await source.rename(target); diff --git a/lib/core/services/sync/changeset_log/resumable_base_publish.dart b/lib/core/services/sync/changeset_log/resumable_base_publish.dart index 19b7171597..0ba80e97b0 100644 --- a/lib/core/services/sync/changeset_log/resumable_base_publish.dart +++ b/lib/core/services/sync/changeset_log/resumable_base_publish.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart' show FlutterError; import 'package:flutter/services.dart' show MissingPluginException; +import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; /// A full base export that has been written to disk but not yet fully uploaded. @@ -298,7 +299,29 @@ Future resolveBasePublishDir() async { if (!e.toString().contains('Binding has not yet been initialized')) rethrow; base = Directory.systemTemp; } - final dir = Directory('${base.path}/sync_base_publish'); + final dir = Directory(p.join(base.path, 'sync_base_publish')); await dir.create(recursive: true); return dir; } + +/// Where an export at [sourcePath] is moved to inside the publish directory. +/// +/// Takes the name with [p.Context.basename] rather than splitting on +/// [Platform.pathSeparator]. Sync assembles its paths by interpolating a +/// literal `/`, so on Windows an export path mixes separators +/// (`C:\Users\...\Local\Temp/ssv1_base_x.json`) and a backslash split kept the +/// `Temp/` in front of the name. That moved the export into a subdirectory of +/// the publish directory which nothing ever creates, so every base publish on +/// Windows failed with `PathNotFoundException` (#1304). `basename` treats both +/// separators as separators under the Windows style, so either shape resolves. +/// +/// [context] exists so the Windows style can be pinned in tests running on a +/// POSIX host; production always wants the platform's own. +String basePublishTargetPath( + String publishDirPath, + String sourcePath, { + p.Context? context, +}) { + final ctx = context ?? p.context; + return ctx.join(publishDirPath, ctx.basename(sourcePath)); +} diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index a942e36284..a845705a42 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:drift/drift.dart'; +import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; import 'package:submersion/core/data/repositories/sync_repository.dart'; @@ -982,8 +983,13 @@ class SyncDataSerializer { Future Function()? tempDir, }) async { final dir = await (tempDir?.call() ?? resolveSyncTempDir()); - final path = - '${dir.path}/ssv1_base_${deviceId}_${seq ?? 0}.${_baseTempUuid.v4()}.json'; + // p.join, not a literal '/': on Windows the temp dir is backslashed, and a + // path mixing both separators is what broke the move into the publish + // directory in #1304. + final path = p.join( + dir.path, + 'ssv1_base_${deviceId}_${seq ?? 0}.${_baseTempUuid.v4()}.json', + ); final raf = await File(path).open(mode: FileMode.write); final digestSink = _Sha256DigestSink(); final dataHash = sha256.startChunkedConversion(digestSink); diff --git a/test/core/services/sync/changeset_log/resumable_base_publish_test.dart b/test/core/services/sync/changeset_log/resumable_base_publish_test.dart index 57765e7328..8b21edc90c 100644 --- a/test/core/services/sync/changeset_log/resumable_base_publish_test.dart +++ b/test/core/services/sync/changeset_log/resumable_base_publish_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/database_service.dart'; @@ -252,6 +253,63 @@ void main() { }); }); + /// Issue #1304: every base publish on Windows died with + /// `PathNotFoundException` because the export's basename was taken by + /// splitting on `Platform.pathSeparator`. Export paths are assembled by + /// interpolation, so on Windows they mix separators and the backslash split + /// left a leading `Temp/` on the name -- moving the export into a + /// subdirectory of the publish dir that nothing ever creates. + /// + /// The host running these tests is POSIX, so the Windows style is pinned + /// explicitly rather than inherited from the platform. + group('basePublishTargetPath', () { + final windows = p.Context(style: p.Style.windows); + const name = + 'ssv1_base_11d9442e-c184-4e1f-8fd1-8f6dc8ff8f05_1.' + '7dd38a42-104f-495a-937d-48ced42d37bd.json'; + + const publishDirPath = + 'C:\\Users\\chaeh\\AppData\\Roaming\\Eric Griffin\\submersion' + '\\sync_base_publish'; + + test('lands a mixed-separator Windows export in the publish dir', () { + // Exactly the shapes from the #1304 report: the temp dir carries + // backslashes, the filename was appended with a literal '/'. + final target = basePublishTargetPath( + publishDirPath, + 'C:\\Users\\chaeh\\AppData\\Local\\Temp/$name', + context: windows, + ); + + expect(windows.basename(target), name); + expect( + windows.dirname(target), + publishDirPath, + reason: 'a "Temp" segment here is a directory that never gets created', + ); + }); + + test('lands a pure-backslash Windows export in the publish dir', () { + final target = basePublishTargetPath( + publishDirPath, + 'C:\\Users\\chaeh\\AppData\\Local\\Temp\\$name', + context: windows, + ); + + expect(target, '$publishDirPath\\$name'); + }); + + test('leaves POSIX paths flat in the publish dir', () { + final target = basePublishTargetPath( + '/var/support/sync_base_publish', + '/var/folders/t7/T/$name', + context: p.Context(style: p.Style.posix), + ); + + expect(target, '/var/support/sync_base_publish/$name'); + }); + }); + /// Part skipping lives here rather than at the writer, where the 8 MiB part /// size would need a multi-megabyte fixture to produce a second part. group('BasePartFileSource.skipPart', () { From deb9ac5763969e3e84af9c43e801bbf35b053ffb Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 16:26:59 -0400 Subject: [PATCH 112/122] fix(dive-detail): give a paired row the gap of the slot it occupies Review catch. The leading spacer was decided from `pair.left`, a fixed property of the pair, rather than from the half whose slot the row actually lands in. Those are the same section only while the diver leaves the left half ordered first. Order Environment above Details and the row renders in Environment's slot but inherits Details' exemption from the 24px gap, butting against whatever precedes it. Key the decision off the loop's `id` instead. Details keeps its exemption because it is the section that emits no gap of its own, which is a fact about that section's slot, not about which side of a pair it sits on. The test pumps the same dive under both orderings and asserts the row sits 24px lower when Environment leads. It needs an explicit teardown between the two pumps: layering a second ProviderScope over a live one keeps the existing SettingsNotifier, so the second section order never reaches the page and both measurements come back identical -- which reads exactly like the bug being absent. --- .../presentation/pages/dive_detail_page.dart | 9 ++-- ...dive_detail_page_paired_sections_test.dart | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 6857e2f438..f752a5eafd 100644 --- a/lib/features/dive_log/presentation/pages/dive_detail_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_detail_page.dart @@ -683,9 +683,12 @@ class _DiveDetailPageState extends ConsumerState { settings: settings, ); if (cards != null) { - // Details is the one section that emits no leading gap of its own - // (it butts against the profile chart); the pair keeps that. - if (pair.left != DiveDetailSectionId.details) { + // The row takes the leading gap of the slot it lands in, which is + // this half's -- not necessarily the pair's left half, since the + // diver may have ordered the right half first. Details is the one + // section that emits no gap of its own (it butts against the + // profile chart). + if (id != DiveDetailSectionId.details) { children.add(const SizedBox(height: 24)); } children.add( diff --git a/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart b/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart index 63dcab8347..a3b29f55d9 100644 --- a/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart +++ b/test/features/dive_log/presentation/pages/dive_detail_page_paired_sections_test.dart @@ -307,6 +307,52 @@ void main() { expect((detailsY - envY).abs(), lessThan(4)); expect(notesY, greaterThan(detailsY)); }); + + testWidgets('takes the leading gap of the slot it occupies', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + // Details is the one section that emits no leading gap of its own; + // Environment emits the usual 24. A pair renders at the slot of + // whichever half the diver ordered first, so that is the gap it must + // inherit -- not whichever half the pair table calls the left one. + final dive = _diveWithConditions('gap-slot'); + + Future pairTop(List order) async { + // Tear the tree down first: pumping a second ProviderScope over the + // live one keeps the existing SettingsNotifier, so the new section + // order would never reach the page. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpWidget( + _buildTestWidget( + dive: dive, + settings: _settingsWithOrder(order), + extraOverrides: _renderOverrides(dive.id, prefs), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(ResponsiveSectionPair), findsOneWidget); + // Details stays on the left either way. + expect( + tester.getTopLeft(find.text('Details')).dx, + lessThan(tester.getTopLeft(find.text('Environment')).dx), + ); + return tester.getTopLeft(find.byType(ResponsiveSectionPair)).dy; + } + + final detailsFirst = await pairTop([ + DiveDetailSectionId.details, + DiveDetailSectionId.environment, + ]); + final environmentFirst = await pairTop([ + DiveDetailSectionId.environment, + DiveDetailSectionId.details, + ]); + + expect(environmentFirst - detailsFirst, 24); + }); }); group('Buddies + Signatures pairing', () { From 9a9d59977f59c5dfdb2f1c9a4eb25c7b4fc3b4e3 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 16:42:48 -0400 Subject: [PATCH 113/122] test: cover the weekday filter's UI wiring in the sheet and Advanced Search Codecov reported 77.33% patch coverage on this PR. The 17 cold lines were all callback bodies and collection-if subtrees that no test drove: the weekday chips' onChanged, the conditional "Clear weekdays" affordance and its onPressed, the date-section auto-expansion for a weekdays-only filter, and the Monday-first branch of the locale week-start conversion (en_US is Sunday-first, so the existing selector tests could only ever reach the other branch). - weekday_filter_selector_test.dart: a German-locale case that asserts the chip row starts on Monday, guarded by an assertion on firstDayOfWeekIndex so the test cannot pass for the wrong reason. - dive_filter_sheet_weekday_test.dart: seeded selection renders and survives Apply, tapping a chip adds its weekday, Clear weekdays empties the axis. - dive_search_page_weekday_test.dart: a weekdays-only filter auto-expands the date section, chip + Search writes the weekday back, Clear weekdays drops the axis, and Clear All wipes a seeded selection. Patch coverage is now 100% (75/75 instrumented added lines). No production code changed. --- .../pages/dive_search_page_weekday_test.dart | 159 ++++++++++++++++ .../dive_filter_sheet_weekday_test.dart | 170 ++++++++++++++++++ .../widgets/weekday_filter_selector_test.dart | 23 +++ 3 files changed, 352 insertions(+) create mode 100644 test/features/dive_log/presentation/pages/dive_search_page_weekday_test.dart create mode 100644 test/features/dive_log/presentation/widgets/dive_filter_sheet_weekday_test.dart diff --git a/test/features/dive_log/presentation/pages/dive_search_page_weekday_test.dart b/test/features/dive_log/presentation/pages/dive_search_page_weekday_test.dart new file mode 100644 index 0000000000..2706cf2749 --- /dev/null +++ b/test/features/dive_log/presentation/pages/dive_search_page_weekday_test.dart @@ -0,0 +1,159 @@ +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/dive_log/presentation/pages/dive_search_page.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/weekday_filter_selector.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +/// Weekday axis as driven from Advanced Search. The page keeps its own draft +/// copy of every filter and only writes it back on Search, so the seeding, +/// the section auto-expansion and the write-back all need exercising here +/// rather than through [WeekdayFilterSelector] alone. +void main() { + setUp(() async { + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future pumpSearchPage( + WidgetTester tester, { + required DiveFilterState initial, + }) async { + final overrides = await getBaseOverrides(); + + final router = GoRouter( + initialLocation: '/dives/search', + routes: [ + GoRoute( + path: '/dives', + builder: (_, _) => const Scaffold(body: Text('dive list')), + routes: [ + GoRoute(path: 'search', builder: (_, _) => const DiveSearchPage()), + ], + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + diveFilterProvider.overrideWith((ref) => initial), + ].cast(), + child: MaterialApp.router( + // Pinned: this suite drives the page by English label. + locale: const Locale('en'), + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); + return ProviderScope.containerOf(tester.element(find.byType(MaterialApp))); + } + + /// The page's sections build lazily, so a target below the fold has no + /// element for `ensureVisible` to work from. Scroll incrementally instead, + /// which keeps the list building content as it goes. + Future scrollTo(WidgetTester tester, Finder finder) async { + if (finder.evaluate().isEmpty) { + await tester.scrollUntilVisible( + finder, + 100, + scrollable: find.byType(Scrollable).first, + ); + } + await tester.ensureVisible(finder.first); + await tester.pumpAndSettle(); + } + + Finder weekdayChips() => find.descendant( + of: find.byType(WeekdayFilterSelector), + matching: find.byType(FilterChip), + ); + + testWidgets('a seeded weekday filter auto-expands the date section', ( + tester, + ) async { + // Weekdays alone, with no date range, still has to open the section that + // holds them, or the diver cannot see the filter that is in force. + await pumpSearchPage( + tester, + initial: const DiveFilterState(weekdays: [DateTime.tuesday]), + ); + + await scrollTo(tester, find.byType(WeekdayFilterSelector)); + + final chips = tester.widgetList(weekdayChips()); + expect(chips.where((c) => c.selected).length, 1); + expect(find.text('Clear weekdays'), findsOneWidget); + }); + + testWidgets('tapping a chip then Search writes the weekday back', ( + tester, + ) async { + final container = await pumpSearchPage( + tester, + initial: const DiveFilterState(weekdays: [DateTime.tuesday]), + ); + + await scrollTo(tester, find.byType(WeekdayFilterSelector)); + + // en_US is Sunday-first, so the first chip is Sunday, which the seed + // leaves unselected. + await tester.tap(weekdayChips().first); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + + expect( + container.read(diveFilterProvider).weekdays, + containsAll([DateTime.tuesday, DateTime.sunday]), + ); + }); + + testWidgets('Clear weekdays drops the axis on Search', (tester) async { + final container = await pumpSearchPage( + tester, + initial: const DiveFilterState( + weekdays: [DateTime.monday, DateTime.thursday], + ), + ); + + await scrollTo(tester, find.text('Clear weekdays')); + await tester.tap(find.text('Clear weekdays')); + await tester.pumpAndSettle(); + + expect(find.text('Clear weekdays'), findsNothing); + + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + + expect(container.read(diveFilterProvider).weekdays, isEmpty); + }); + + testWidgets('Clear All drops a seeded weekday selection', (tester) async { + await pumpSearchPage( + tester, + initial: const DiveFilterState(weekdays: [DateTime.saturday]), + ); + + await tester.tap(find.text('Clear All')); + await tester.pumpAndSettle(); + + await scrollTo(tester, find.byType(WeekdayFilterSelector)); + final chips = tester.widgetList(weekdayChips()); + expect(chips.where((c) => c.selected), isEmpty); + expect(find.text('Clear weekdays'), findsNothing); + }); +} diff --git a/test/features/dive_log/presentation/widgets/dive_filter_sheet_weekday_test.dart b/test/features/dive_log/presentation/widgets/dive_filter_sheet_weekday_test.dart new file mode 100644 index 0000000000..cc5bc2fb40 --- /dev/null +++ b/test/features/dive_log/presentation/widgets/dive_filter_sheet_weekday_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/dive_filter_sheet.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/weekday_filter_selector.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +/// Drives the weekday axis through the sheet itself. The standalone +/// [WeekdayFilterSelector] tests cover the widget in isolation; what is only +/// reachable from here is the sheet's own wiring: the seeded selection, the +/// conditional "Clear weekdays" affordance, and the write-back on Apply. +void main() { + // A test-owned filter provider so each test starts from a known state and + // can assert what the sheet writes back. + late StateProvider filterProvider; + + setUp(() async { + await setUpTestDatabase(); + filterProvider = StateProvider( + (ref) => const DiveFilterState(), + ); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + /// Pumps a scaffold with a button that opens the sheet in a modal bottom + /// sheet (so the sheet's Navigator.pop closes it cleanly), returning the + /// captured [WidgetRef] for reading the filter provider afterwards. + Future openSheet( + WidgetTester tester, { + DiveFilterState initial = const DiveFilterState(), + }) async { + final overrides = await getBaseOverrides(); + late WidgetRef capturedRef; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...overrides, + filterProvider.overrideWith((ref) => initial), + ].cast(), + child: MaterialApp( + // Pinned: this suite drives the sheet by English label. + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + capturedRef = ref; + return Center( + child: ElevatedButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => DiveFilterSheet( + ref: ref, + filterProvider: filterProvider, + ), + ), + child: const Text('Open'), + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + return capturedRef; + } + + Future scrollTo(WidgetTester tester, Finder finder) async { + // The sheet's ListView builds children lazily, so the weekday section is + // not in the tree until it is scrolled near. Pass a PLAIN finder: + // `evaluate()` on an index-qualified one throws "Bad state: No element" + // when nothing has been built yet. + if (finder.evaluate().isEmpty) { + await tester.scrollUntilVisible( + finder, + 60.0, + scrollable: find.byType(Scrollable).first, + ); + } + await tester.ensureVisible(finder.first); + await tester.pumpAndSettle(); + } + + /// Only the weekday chips, so the tag chips further down the sheet cannot + /// be picked up by a bare `find.byType(FilterChip)`. + Finder weekdayChips() => find.descendant( + of: find.byType(WeekdayFilterSelector), + matching: find.byType(FilterChip), + ); + + Future tapText(WidgetTester tester, String label) async { + await scrollTo(tester, find.text(label)); + await tester.tap(find.text(label).first); + await tester.pumpAndSettle(); + } + + testWidgets('a seeded weekday selection is shown and survives Apply', ( + tester, + ) async { + final ref = await openSheet( + tester, + initial: const DiveFilterState(weekdays: [DateTime.saturday]), + ); + + await scrollTo(tester, find.byType(WeekdayFilterSelector)); + + // The seeded weekday arrives selected, and the clear affordance only + // renders because something is selected. + final chips = tester.widgetList(weekdayChips()); + expect(chips.where((c) => c.selected).length, 1); + expect(find.text('Clear weekdays'), findsOneWidget); + + await tapText(tester, 'Apply Filters'); + expect(ref.read(filterProvider).weekdays, [DateTime.saturday]); + }); + + testWidgets('tapping a chip adds its weekday to the applied filter', ( + tester, + ) async { + final ref = await openSheet(tester); + + await scrollTo(tester, find.byType(WeekdayFilterSelector)); + + // Nothing is selected yet, so the clear affordance is absent. + expect(find.text('Clear weekdays'), findsNothing); + + // en_US is Sunday-first, so the first chip is Sunday. + await tester.tap(weekdayChips().first); + await tester.pumpAndSettle(); + + expect(find.text('Clear weekdays'), findsOneWidget); + + await tapText(tester, 'Apply Filters'); + expect(ref.read(filterProvider).weekdays, [DateTime.sunday]); + }); + + testWidgets('Clear weekdays empties the selection', (tester) async { + final ref = await openSheet( + tester, + initial: const DiveFilterState( + weekdays: [DateTime.monday, DateTime.wednesday, DateTime.friday], + ), + ); + + await tapText(tester, 'Clear weekdays'); + + // The affordance removes itself along with the selection it clears. + expect(find.text('Clear weekdays'), findsNothing); + final chips = tester.widgetList(weekdayChips()); + expect(chips.where((c) => c.selected), isEmpty); + + await tapText(tester, 'Apply Filters'); + expect(ref.read(filterProvider).weekdays, isEmpty); + }); +} diff --git a/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart b/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart index 2a62c97755..3d549949a2 100644 --- a/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart +++ b/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart @@ -56,6 +56,29 @@ void main() { expect(labelText, expectedFirstLabel); }); + testWidgets('a Monday-first locale starts the row on Monday', (tester) async { + // The default `en` (en_US) locale is Sunday-first, so it only ever + // exercises one branch of the week-start conversion. German is + // Monday-first, which is the other branch. + final context = await _pump( + tester, + selected: const [], + onChanged: (_) {}, + locale: const Locale('de'), + ); + + // Guards the premise of this test rather than the widget: if the bundled + // German data ever changed its week start, the assertion below would pass + // for the wrong reason. + expect(MaterialLocalizations.of(context).firstDayOfWeekIndex, 1); + + final firstChip = tester.widget(find.byType(FilterChip).first); + expect( + (firstChip.label as Text).data, + weekdayAbbreviation(context, DateTime.monday), + ); + }); + testWidgets('marks selected weekdays as selected chips', (tester) async { await _pump(tester, selected: const [1], onChanged: (_) {}); From d4f170a11480fcb0423a4ac8b0c686a26809e0ec Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 16:43:32 -0400 Subject: [PATCH 114/122] feat(gps-log): map-first desktop layout, summary strip, and empty state At desktop width the GPS log page hosts the overview map beside its track list via MapListScaffold; rows select on the map and the info card opens the track. A summary strip (tracks, recorded time, dives covered) tops both layouts, the empty state explains the feature, and an empty basemap replaces the blank map pane. Shared list tile, stat tile, date filter action and overview map widgets keep the logger page and the track map page from drifting apart. --- .../presentation/pages/gps_logger_page.dart | 179 ++++++++++++----- .../pages/gps_track_map_page.dart | 188 ++---------------- .../providers/gps_log_providers.dart | 53 ++++- .../widgets/gps_log_empty_state.dart | 45 +++++ .../widgets/gps_log_list_pane.dart | 99 +++++++++ .../widgets/gps_log_summary_strip.dart | 48 +++++ .../widgets/gps_track_date_filter_action.dart | 59 ++++++ .../widgets/gps_track_empty_map.dart | 47 +++++ .../widgets/gps_track_info_card.dart | 47 +++++ .../widgets/gps_track_list_tile.dart | 56 ++++++ .../widgets/gps_track_overview_map.dart | 133 +++++++++++++ .../widgets/track_row_labels.dart | 34 +++- .../presentation/widgets/track_stat_tile.dart | 31 +++ .../widgets/track_stats_header.dart | 17 +- .../map_list_layout/map_list_scaffold.dart | 11 +- .../gps_log_summary_provider_test.dart | 132 ++++++++++++ .../gps_log/gps_logger_page_test.dart | 176 ++++++++++++++++ .../gps_log/gps_track_map_page_test.dart | 37 +++- 18 files changed, 1143 insertions(+), 249 deletions(-) create mode 100644 lib/features/gps_log/presentation/widgets/gps_log_empty_state.dart create mode 100644 lib/features/gps_log/presentation/widgets/gps_log_list_pane.dart create mode 100644 lib/features/gps_log/presentation/widgets/gps_log_summary_strip.dart create mode 100644 lib/features/gps_log/presentation/widgets/gps_track_date_filter_action.dart create mode 100644 lib/features/gps_log/presentation/widgets/gps_track_empty_map.dart create mode 100644 lib/features/gps_log/presentation/widgets/gps_track_info_card.dart create mode 100644 lib/features/gps_log/presentation/widgets/gps_track_list_tile.dart create mode 100644 lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart create mode 100644 lib/features/gps_log/presentation/widgets/track_stat_tile.dart create mode 100644 test/features/gps_log/gps_log_summary_provider_test.dart diff --git a/lib/features/gps_log/presentation/pages/gps_logger_page.dart b/lib/features/gps_log/presentation/pages/gps_logger_page.dart index 5582968930..9ebbacd6d1 100644 --- a/lib/features/gps_log/presentation/pages/gps_logger_page.dart +++ b/lib/features/gps_log/presentation/pages/gps_logger_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; @@ -18,14 +19,29 @@ import 'package:submersion/features/gps_log/presentation/pages/track_import_revi import 'package:submersion/features/gps_log/presentation/track_parse_error_text.dart'; import 'package:submersion/features/gps_log/presentation/providers/gps_log_providers.dart'; import 'package:submersion/features/gps_log/presentation/providers/gps_track_map_providers.dart'; -import 'package:submersion/features/gps_log/presentation/widgets/gps_track_thumbnail.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_log_empty_state.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_log_list_pane.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_log_summary_strip.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_date_filter_action.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_empty_map.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_info_card.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_list_tile.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_overview_map.dart'; import 'package:submersion/features/gps_log/presentation/widgets/track_row_labels.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/providers/map_list_selection_provider.dart'; import 'package:submersion/shared/widgets/feature_accent.dart'; +import 'package:submersion/shared/widgets/map_list_layout/map_list_scaffold.dart'; +import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart'; /// GPS surface track logger (discussion #289): record the phone's position /// during a dive day; imported dives are matched to positions by timestamp. +/// +/// Two layouts. Below the master-detail breakpoint it is a single column: +/// record card (phones only), summary, match action, track list; a row opens +/// the track. At desktop width the same list sits beside the overview map, +/// a row selects the track on the map, and the map's info card opens it. class GpsLoggerPage extends ConsumerStatefulWidget { const GpsLoggerPage({super.key}); @@ -35,6 +51,7 @@ class GpsLoggerPage extends ConsumerStatefulWidget { class _GpsLoggerPageState extends ConsumerState { final _log = LoggerService.forClass(GpsLoggerPage); + final MapController _mapController = MapController(); /// Recording only makes sense on the device that goes on the boat. /// defaultTargetPlatform (not dart:io) so widget tests can override it. @@ -211,29 +228,116 @@ class _GpsLoggerPageState extends ConsumerState { ], ), ); - if (confirmed == true) { - await ref.read(deleteTrackProvider)(track.id); + if (confirmed != true) return; + await ref.read(deleteTrackProvider)(track.id); + // A deleted track must not stay picked on the map. + final selection = ref.read(mapListSelectionProvider(kGpsTrackSectionKey)); + if (selection.selectedId == track.id) { + ref + .read(mapListSelectionProvider(kGpsTrackSectionKey).notifier) + .deselect(); } } - /// Compact duration, matching the app-wide dive_field_formatter style - /// ("Xh Ym" at an hour or more, "Xmin" below). - String _formatCompactDuration(Duration duration) { - if (duration.inHours < 1) return '${duration.inMinutes}min'; - return '${duration.inHours}h ${duration.inMinutes % 60}m'; - } + void _openTrack(String id) => context.push('/gps-log/$id'); String _formatAge(DateTime lastFixAt) { final age = DateTime.now().toUtc().difference(lastFixAt); if (age.inMinutes < 1) return '<1min'; - return _formatCompactDuration(age); + return formatCompactDuration(age); } + Widget _importAction(BuildContext context) => IconButton( + key: const ValueKey('gps-track-import'), + icon: const Icon(Icons.file_open_outlined), + tooltip: context.l10n.gpsTrack_import_action, + onPressed: _importTrack, + ); + @override Widget build(BuildContext context) { + return ResponsiveBreakpoints.isMasterDetail(context) + ? _buildSplit(context) + : _buildColumn(context); + } + + /// Desktop: list pane beside the overview map, sharing the map page's + /// selection section so a track picked on either surface stays picked. + Widget _buildSplit(BuildContext context) { + final l10n = context.l10n; + // Capped: every track drawn here hydrates a full point blob and, on a cold + // cache, spawns its own simplification isolate. + final tracksAsync = ref.watch(overviewTracksProvider); + final tracks = tracksAsync.value ?? const []; + final truncated = ref.watch(overviewTracksTruncatedProvider); + final selection = ref.watch(mapListSelectionProvider(kGpsTrackSectionKey)); + final selected = tracks + .where((t) => t.id == selection.selectedId) + .firstOrNull; + final recorder = ref.watch(gpsTrackRecorderProvider); + final state = ref.watch(gpsRecorderStateProvider).value ?? recorder.state; + + return MapListScaffold( + sectionKey: kGpsTrackSectionKey, + title: l10n.tools_gpsLogger_title, + titleWidget: FeatureAppBarTitle( + featureId: 'gps-log', + title: l10n.tools_gpsLogger_title, + ), + actions: [const GpsTrackDateFilterAction(), _importAction(context)], + listPane: GpsLogListPane( + tracks: tracks, + selectedId: selection.selectedId, + leading: _canRecord + ? _RecordCard( + state: state, + formatAge: _formatAge, + onStart: _startLogging, + onStop: () => ref.read(gpsTrackRecorderProvider).stop(), + ) + : null, + truncatedNotice: truncated + ? l10n.gpsTrack_map_truncated(kOverviewTrackLimit) + : null, + onMatch: _matchNow, + onSelect: (id) => ref + .read(mapListSelectionProvider(kGpsTrackSectionKey).notifier) + .select(id), + onDelete: _deleteTrack, + ), + // Same three-way split as the map page: a loading library must not + // flash the empty message, and a failed query must not claim there + // are no tracks. + mapPane: switch (tracksAsync) { + AsyncLoading() when tracks.isEmpty => const Center( + child: CircularProgressIndicator(), + ), + AsyncError() => Center(child: Text(l10n.common_error_tryAgain)), + _ when tracks.isEmpty => GpsTrackEmptyMap( + message: l10n.gpsTrack_map_noTracks, + ), + _ => GpsTrackOverviewMap( + tracks: tracks, + selectedId: selection.selectedId, + controller: _mapController, + ), + }, + infoCard: selected == null + ? null + : GpsTrackInfoCard( + track: selected, + onDetailsTap: () => _openTrack(selected.id), + onClose: () => ref + .read(mapListSelectionProvider(kGpsTrackSectionKey).notifier) + .deselect(), + ), + ); + } + + /// Phones and narrow windows: one column, a row opens the track. + Widget _buildColumn(BuildContext context) { final l10n = context.l10n; final theme = Theme.of(context); - final units = UnitFormatter(ref.watch(settingsProvider)); final recorder = ref.watch(gpsTrackRecorderProvider); final state = ref.watch(gpsRecorderStateProvider).value ?? recorder.state; final tracks = ref.watch(gpsTracksProvider).value ?? const []; @@ -250,15 +354,10 @@ class _GpsLoggerPageState extends ConsumerState { tooltip: l10n.gpsTrack_map_showMap, onPressed: () => context.push('/gps-log/map'), ), - IconButton( - key: const ValueKey('gps-track-import'), - icon: const Icon(Icons.file_open_outlined), - tooltip: l10n.gpsTrack_import_action, - onPressed: _importTrack, - ), + _importAction(context), ], ), - // CustomScrollView rather than ListView: each row now carries a live + // CustomScrollView rather than ListView: each row carries a live // FlutterMap thumbnail, and a non-builder list would instantiate one // per track in the database on first paint. body: CustomScrollView( @@ -276,29 +375,16 @@ class _GpsLoggerPageState extends ConsumerState { ), const SizedBox(height: 16), ], - OutlinedButton.icon( - icon: const Icon(Icons.add_location_alt_outlined), - label: Text(l10n.gpsLogger_matchButton), - onPressed: _matchNow, - ), + const GpsLogSummaryStrip(), + const SizedBox(height: 12), + GpsLogMatchButton(onPressed: _matchNow), const SizedBox(height: 24), Text( l10n.gpsLogger_tracksHeader, style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), - if (tracks.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Center( - child: Text( - l10n.gpsLogger_noTracks, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ), + if (tracks.isEmpty) const GpsLogEmptyState(), ], ), ), @@ -308,30 +394,15 @@ class _GpsLoggerPageState extends ConsumerState { itemCount: tracks.length, itemBuilder: (context, index) { final track = tracks[index]; - return ListTile( + return GpsTrackListTile( // Keyed by track: without this a recycled row keeps the // previous track's FlutterMap State, and its camera stays // on the previous region. key: ValueKey(track.id), - onTap: () => context.push('/gps-log/${track.id}'), + track: track, contentPadding: EdgeInsets.zero, - minLeadingWidth: kTrackThumbnailWidth, - leading: GpsTrackThumbnail(trackId: track.id), - // Track times are wall-clock-as-UTC: format the UTC - // components directly, never convert to device-local. - title: Text(formatTrackStart(units, track)), - subtitle: Text( - formatTrackSubtitle( - l10n, - track, - formatTrackDuration(track), - ), - ), - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: l10n.common_action_delete, - onPressed: () => _deleteTrack(track), - ), + onTap: () => _openTrack(track.id), + onDelete: () => _deleteTrack(track), ); }, ), diff --git a/lib/features/gps_log/presentation/pages/gps_track_map_page.dart b/lib/features/gps_log/presentation/pages/gps_track_map_page.dart index 92c6087166..95b44159d8 100644 --- a/lib/features/gps_log/presentation/pages/gps_track_map_page.dart +++ b/lib/features/gps_log/presentation/pages/gps_track_map_page.dart @@ -1,31 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'package:latlong2/latlong.dart'; import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/core/utils/unit_formatter.dart'; -import 'package:submersion/features/gps_log/data/repositories/track_geometry_cache_repository.dart'; import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; import 'package:submersion/features/gps_log/presentation/providers/gps_track_map_providers.dart'; -import 'package:submersion/features/gps_log/presentation/widgets/gps_track_thumbnail.dart'; -import 'package:submersion/features/gps_log/presentation/widgets/track_camera.dart'; -import 'package:submersion/features/gps_log/presentation/widgets/track_row_labels.dart'; -import 'package:submersion/features/maps/presentation/widgets/map_attribution.dart'; -import 'package:submersion/features/maps/presentation/widgets/map_compass_button.dart'; -import 'package:submersion/features/maps/presentation/widgets/map_interaction_options.dart'; -import 'package:submersion/features/maps/presentation/widgets/submersion_tile_layer.dart'; -import 'package:submersion/features/maps/presentation/widgets/trackpad_zoom_map.dart'; -import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_date_filter_action.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_empty_map.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_list_tile.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_overview_map.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/providers/map_list_selection_provider.dart'; -import 'package:submersion/shared/widgets/app_date_picker.dart'; import 'package:submersion/shared/widgets/map_list_layout/map_list_scaffold.dart'; -const String _kSectionKey = 'gps-tracks'; - /// Every recorded track on one map, bound to a list pane on desktop. +/// +/// At desktop width the GPS log page hosts this same split itself; this route +/// stays for phones, where the log is a single column, and for deep links. class GpsTrackMapPage extends ConsumerStatefulWidget { const GpsTrackMapPage({super.key}); @@ -36,19 +27,6 @@ class GpsTrackMapPage extends ConsumerStatefulWidget { class _GpsTrackMapPageState extends ConsumerState { final MapController _mapController = MapController(); - Future _pickRange() async { - final existing = ref.read(trackDateFilterProvider); - final picked = await showAppDateRangePicker( - context: context, - firstDate: DateTime(2000), - lastDate: DateTime(2100), - initialDateRange: existing, - ); - if (picked != null) { - ref.read(trackDateFilterProvider.notifier).state = picked; - } - } - @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -57,34 +35,13 @@ class _GpsTrackMapPageState extends ConsumerState { final tracksAsync = ref.watch(overviewTracksProvider); final tracks = tracksAsync.value ?? const []; final truncated = ref.watch(overviewTracksTruncatedProvider); - final selection = ref.watch(mapListSelectionProvider(_kSectionKey)); - final range = ref.watch(trackDateFilterProvider); + final selection = ref.watch(mapListSelectionProvider(kGpsTrackSectionKey)); return MapListScaffold( - sectionKey: _kSectionKey, + sectionKey: kGpsTrackSectionKey, title: l10n.gpsTrack_map_title, onBackPressed: () => context.go('/gps-log'), - actions: [ - TextButton.icon( - key: const ValueKey('gps-track-date-filter'), - icon: const Icon(Icons.date_range), - label: Text( - range == null - ? l10n.gpsTrack_filter_all - : '${DateFormat.yMd().format(range.start)} - ' - '${DateFormat.yMd().format(range.end)}', - ), - onPressed: _pickRange, - ), - if (range != null) - IconButton( - key: const ValueKey('gps-track-date-filter-clear'), - icon: const Icon(Icons.filter_alt_off_outlined), - tooltip: l10n.gpsTrack_filter_clear, - onPressed: () => - ref.read(trackDateFilterProvider.notifier).state = null, - ), - ], + actions: const [GpsTrackDateFilterAction()], listPane: _TrackListPane( tracks: tracks, selectedId: selection.selectedId, @@ -101,10 +58,10 @@ class _GpsTrackMapPageState extends ConsumerState { child: CircularProgressIndicator(), ), AsyncError() => Center(child: Text(l10n.common_error_tryAgain)), - _ when tracks.isEmpty => Center( - child: Text(l10n.gpsTrack_map_noTracks), + _ when tracks.isEmpty => GpsTrackEmptyMap( + message: l10n.gpsTrack_map_noTracks, ), - _ => _OverviewMap( + _ => GpsTrackOverviewMap( tracks: tracks, selectedId: selection.selectedId, controller: _mapController, @@ -129,8 +86,6 @@ class _TrackListPane extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final l10n = context.l10n; - final units = UnitFormatter(ref.watch(settingsProvider)); final notice = truncatedNotice; return ListView.builder( // The notice occupies index 0 so it scrolls with the rows rather than @@ -153,126 +108,15 @@ class _TrackListPane extends ConsumerWidget { index -= 1; } final track = tracks[index]; - return ListTile( - // See gps_logger_page: an unkeyed recycled row keeps the previous - // track's map camera. + return GpsTrackListTile( key: ValueKey(track.id), + track: track, selected: track.id == selectedId, - leading: GpsTrackThumbnail(trackId: track.id), - minLeadingWidth: kTrackThumbnailWidth, - title: Text(formatTrackStart(units, track)), - subtitle: Text( - formatTrackSubtitle(l10n, track, formatTrackDuration(track)), - ), onTap: () => ref - .read(mapListSelectionProvider(_kSectionKey).notifier) + .read(mapListSelectionProvider(kGpsTrackSectionKey).notifier) .select(track.id), ); }, ); } } - -class _OverviewMap extends ConsumerStatefulWidget { - const _OverviewMap({ - required this.tracks, - required this.selectedId, - required this.controller, - }); - - final List tracks; - final String? selectedId; - final MapController controller; - - @override - ConsumerState<_OverviewMap> createState() => _OverviewMapState(); -} - -class _OverviewMapState extends ConsumerState<_OverviewMap> { - bool _mapReady = false; - - /// Signature of the framing currently applied, so a filter change or a - /// late-arriving simplify re-frames but an unrelated rebuild does not. - String? _framedOn; - - List get tracks => widget.tracks; - String? get selectedId => widget.selectedId; - MapController get controller => widget.controller; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - - // Unselected tracks are muted and drawn first; the selected one is drawn - // last with a thicker stroke so it sits on top of any it overlaps. - final unselected = >[]; - Polyline? selected; - final allPoints = []; - - for (final track in tracks) { - final geometry = - ref - .watch(gpsTrackGeometryProvider((track.id, TrackLod.thumbnail))) - .value ?? - const []; - if (geometry.length < 2) continue; - allPoints.addAll(geometry); - - final line = Polyline( - points: [for (final p in geometry) LatLng(p.latitude, p.longitude)], - color: track.id == selectedId ? scheme.primary : scheme.outline, - strokeWidth: track.id == selectedId ? 4.0 : 2.0, - strokeCap: StrokeCap.round, - hitValue: track.id, - ); - if (track.id == selectedId) { - selected = line; - } else { - unselected.add(line); - } - } - - final camera = TrackCamera.forPoints(allPoints); - if (camera == null) { - return const SizedBox.shrink(); - } - - // Re-frame when the visible set changes: the date filter narrowing, a - // selection promoting a track, or a per-track simplify finishing. A - // FutureProvider reload keeps its previous value, so the map never - // unmounts and initialCameraFit would never apply again. - final signature = '${tracks.length}:${allPoints.length}:$selectedId'; - if (_mapReady && _framedOn != signature) { - _framedOn = signature; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) camera.applyTo(controller); - }); - } - - return TrackpadZoomMap( - controller: controller, - child: FlutterMap( - mapController: controller, - options: MapOptions( - onMapReady: () { - _mapReady = true; - _framedOn = signature; - }, - initialCameraFit: camera.fit, - initialCenter: camera.center ?? const LatLng(0, 0), - initialZoom: camera.zoom ?? 13.0, - interactionOptions: rotatableMapInteraction, - ), - children: [ - submersionTileLayer(ref), - PolylineLayer( - // Selected drawn last so it sits above any track it overlaps. - polylines: [...unselected, ?selected], - ), - const MapAttribution(), - MapCompassButton(controller: controller), - ], - ), - ); - } -} diff --git a/lib/features/gps_log/presentation/providers/gps_log_providers.dart b/lib/features/gps_log/presentation/providers/gps_log_providers.dart index b8517c4161..67c06df601 100644 --- a/lib/features/gps_log/presentation/providers/gps_log_providers.dart +++ b/lib/features/gps_log/presentation/providers/gps_log_providers.dart @@ -1,9 +1,10 @@ import 'package:submersion/core/providers/provider.dart'; -import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/gps_log/data/repositories/gps_track_repository.dart'; import 'package:submersion/features/gps_log/data/services/gps_track_match_service.dart'; import 'package:submersion/features/gps_log/data/services/gps_track_recorder.dart'; import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; +import 'package:submersion/features/gps_log/domain/gps_track_matcher.dart'; final gpsTrackRepositoryProvider = Provider( (ref) => GpsTrackRepository(), @@ -38,3 +39,53 @@ final gpsTracksProvider = FutureProvider>((ref) async { ref.invalidateSelfWhen(repository.watchTracksChanges()); return repository.getCompletedTracks(); }); + +/// Figures for the logger page's summary strip. +/// +/// Everything here comes from stored scalars and the in-memory dive list: +/// no track blob is decoded, so it stays cheap for a library of hundreds of +/// boat days. Distance is deliberately absent - it is not stored, and a +/// figure derived from simplified geometry would disagree with the detail +/// page. +class GpsLogSummary { + final int trackCount; + + /// Total recorded time across completed tracks, each honouring its trim. + final Duration recordedTime; + + /// Dives whose entry falls inside some track's window, by the same + /// tolerance the match sweep and the detail markers apply. + final int divesCovered; + + const GpsLogSummary({ + required this.trackCount, + required this.recordedTime, + required this.divesCovered, + }); +} + +final gpsLogSummaryProvider = FutureProvider((ref) async { + final tracks = await ref.watch(gpsTracksProvider.future); + final dives = await ref.watch(divesProvider.future); + + var recordedMs = 0; + for (final track in tracks) { + final end = track.effectiveEndTime; + if (end == null) continue; + recordedMs += end - track.effectiveStartTime; + } + + var covered = 0; + for (final dive in dives) { + // millisecondsSinceEpoch is absolute regardless of the utc flag, so it + // compares directly against the wall-clock-as-UTC track window. + final entryMs = dive.effectiveEntryTime.millisecondsSinceEpoch; + if (GpsTrackMatcher.trackCovering(tracks, entryMs) != null) covered += 1; + } + + return GpsLogSummary( + trackCount: tracks.length, + recordedTime: Duration(milliseconds: recordedMs), + divesCovered: covered, + ); +}); diff --git a/lib/features/gps_log/presentation/widgets/gps_log_empty_state.dart b/lib/features/gps_log/presentation/widgets/gps_log_empty_state.dart new file mode 100644 index 0000000000..bf7a6ce899 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_log_empty_state.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +/// What the GPS log shows before the first track exists. +/// +/// The single grey sentence this replaces told a diver nothing about why the +/// page was there; the feature description already shipped for the tools hub, +/// so the empty state reuses it rather than adding a second explanation. +class GpsLogEmptyState extends StatelessWidget { + const GpsLogEmptyState({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.route_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + l10n.gpsLogger_noTracks, + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.tools_gpsLogger_description, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/gps_log_list_pane.dart b/lib/features/gps_log/presentation/widgets/gps_log_list_pane.dart new file mode 100644 index 0000000000..495c0c2bca --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_log_list_pane.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_log_empty_state.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_log_summary_strip.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_list_tile.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// "Match dives to GPS logs": sweeps GPS-less dives against every track. +class GpsLogMatchButton extends StatelessWidget { + const GpsLogMatchButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + icon: const Icon(Icons.add_location_alt_outlined), + label: Text(context.l10n.gpsLogger_matchButton), + onPressed: onPressed, + ); + } +} + +/// The list side of the GPS log's desktop split: summary, actions, rows. +/// +/// A row tap selects the track on the map rather than opening it; the map's +/// info card carries the open action, as on the site map. +class GpsLogListPane extends StatelessWidget { + const GpsLogListPane({ + super.key, + required this.tracks, + required this.selectedId, + required this.onMatch, + required this.onSelect, + required this.onDelete, + this.leading, + this.truncatedNotice, + }); + + final List tracks; + final String? selectedId; + final VoidCallback onMatch; + final ValueChanged onSelect; + final ValueChanged onDelete; + + /// Rendered above the summary: the record card on a tablet wide enough + /// for the split, which can still go on the boat. + final Widget? leading; + + /// Set when the overview cap dropped tracks the date filter allowed. + final String? truncatedNotice; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final notice = truncatedNotice; + return ListView.builder( + // The header occupies index 0 so it scrolls with the rows rather than + // stealing height from the pane. + itemCount: tracks.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (leading != null) ...[leading!, const SizedBox(height: 16)], + const GpsLogSummaryStrip(), + const SizedBox(height: 12), + GpsLogMatchButton(onPressed: onMatch), + if (notice != null) ...[ + const SizedBox(height: 12), + Text( + notice, + key: const ValueKey('gps-track-truncated-notice'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (tracks.isEmpty) const GpsLogEmptyState(), + ], + ), + ); + } + final track = tracks[index - 1]; + return GpsTrackListTile( + key: ValueKey(track.id), + track: track, + selected: track.id == selectedId, + onTap: () => onSelect(track.id), + onDelete: () => onDelete(track), + ); + }, + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/gps_log_summary_strip.dart b/lib/features/gps_log/presentation/widgets/gps_log_summary_strip.dart new file mode 100644 index 0000000000..44be3df3d5 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_log_summary_strip.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/gps_log/presentation/providers/gps_log_providers.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/track_row_labels.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/track_stat_tile.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Library-wide figures at the top of the GPS log: how many tracks, how much +/// time they cover, and how many dives fall inside one. +/// +/// Shows placeholders rather than zeros while the figures load, so a cold +/// open never flashes "0 tracks" over a full library. +class GpsLogSummaryStrip extends ConsumerWidget { + const GpsLogSummaryStrip({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final summary = ref.watch(gpsLogSummaryProvider).value; + + final tiles = <(String, String)>[ + (l10n.gpsLogger_summary_tracks, summary?.trackCount.toString() ?? '--'), + ( + l10n.gpsLogger_summary_recordedTime, + summary == null ? '--' : formatCompactDuration(summary.recordedTime), + ), + ( + l10n.gpsLogger_summary_divesCovered, + summary?.divesCovered.toString() ?? '--', + ), + ]; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + for (final (label, value) in tiles) + Expanded( + child: TrackStatTile(label: label, value: value), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/gps_track_date_filter_action.dart b/lib/features/gps_log/presentation/widgets/gps_track_date_filter_action.dart new file mode 100644 index 0000000000..7327ba0976 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_track_date_filter_action.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import 'package:submersion/features/gps_log/presentation/providers/gps_track_map_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/app_date_picker.dart'; + +/// AppBar control for [trackDateFilterProvider]: the active range (or "All +/// dates") and, once a range is set, a clear button. +/// +/// Shared by the overview map page and the GPS log page's desktop split, so +/// the filter reads the same wherever the overview map appears. +class GpsTrackDateFilterAction extends ConsumerWidget { + const GpsTrackDateFilterAction({super.key}); + + Future _pickRange(BuildContext context, WidgetRef ref) async { + final existing = ref.read(trackDateFilterProvider); + final picked = await showAppDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + initialDateRange: existing, + ); + if (picked != null) { + ref.read(trackDateFilterProvider.notifier).state = picked; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final range = ref.watch(trackDateFilterProvider); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + key: const ValueKey('gps-track-date-filter'), + icon: const Icon(Icons.date_range), + label: Text( + range == null + ? l10n.gpsTrack_filter_all + : '${DateFormat.yMd().format(range.start)} - ' + '${DateFormat.yMd().format(range.end)}', + ), + onPressed: () => _pickRange(context, ref), + ), + if (range != null) + IconButton( + key: const ValueKey('gps-track-date-filter-clear'), + icon: const Icon(Icons.filter_alt_off_outlined), + tooltip: l10n.gpsTrack_filter_clear, + onPressed: () => + ref.read(trackDateFilterProvider.notifier).state = null, + ), + ], + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/gps_track_empty_map.dart b/lib/features/gps_log/presentation/widgets/gps_track_empty_map.dart new file mode 100644 index 0000000000..ca210057a8 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_track_empty_map.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/maps/presentation/widgets/map_attribution.dart'; +import 'package:submersion/features/maps/presentation/widgets/map_interaction_options.dart'; +import 'package:submersion/features/maps/presentation/widgets/submersion_tile_layer.dart'; + +/// The overview pane before any track exists (or when the date filter +/// leaves none): a world basemap with the notice floated over it. +/// +/// A bare pane with one grey sentence was the original complaint about this +/// page; a map with nothing drawn on it at least reads as a map waiting for +/// tracks. +class GpsTrackEmptyMap extends ConsumerWidget { + const GpsTrackEmptyMap({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + return Stack( + children: [ + FlutterMap( + options: const MapOptions( + initialCenter: LatLng(20, 0), + initialZoom: 2, + interactionOptions: rotatableMapInteraction, + ), + children: [submersionTileLayer(ref), const MapAttribution()], + ), + Center( + child: Card( + elevation: 4, + color: theme.colorScheme.surfaceContainerHigh, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Text(message, style: theme.textTheme.bodyMedium), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/gps_track_info_card.dart b/lib/features/gps_log/presentation/widgets/gps_track_info_card.dart new file mode 100644 index 0000000000..0a33b397a6 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_track_info_card.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/track_row_labels.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/map_list_layout/map_info_card.dart'; + +/// The card floated over the overview map for the selected track. +/// +/// Same title and figures as the list row it was picked from, by the same +/// formatters, so the card never contradicts the row. +class GpsTrackInfoCard extends ConsumerWidget { + const GpsTrackInfoCard({ + super.key, + required this.track, + required this.onDetailsTap, + required this.onClose, + }); + + final GpsTrack track; + final VoidCallback onDetailsTap; + final VoidCallback onClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final units = UnitFormatter(ref.watch(settingsProvider)); + final scheme = Theme.of(context).colorScheme; + return MapInfoCard( + title: formatTrackTitle(units, track), + subtitle: formatTrackDetailLine(l10n, units, track), + leading: CircleAvatar( + backgroundColor: scheme.primaryContainer, + child: Icon(Icons.route, color: scheme.primary), + ), + trailing: IconButton( + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: onClose, + ), + onDetailsTap: onDetailsTap, + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/gps_track_list_tile.dart b/lib/features/gps_log/presentation/widgets/gps_track_list_tile.dart new file mode 100644 index 0000000000..6b6d459f49 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_track_list_tile.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/gps_track_thumbnail.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/track_row_labels.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// One row in a list of recorded tracks: thumbnail, title, figures. +/// +/// Both surfaces that list tracks (the GPS log page and the overview map's +/// list pane) build their rows here, so they cannot drift apart again. +/// Callers key each tile by track id: a recycled unkeyed row keeps the +/// previous track's thumbnail camera. +class GpsTrackListTile extends ConsumerWidget { + const GpsTrackListTile({ + super.key, + required this.track, + required this.onTap, + this.selected = false, + this.onDelete, + this.contentPadding, + }); + + final GpsTrack track; + final VoidCallback onTap; + final bool selected; + + /// Shown as a trailing delete icon when set. + final VoidCallback? onDelete; + final EdgeInsetsGeometry? contentPadding; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final units = UnitFormatter(ref.watch(settingsProvider)); + return ListTile( + onTap: onTap, + selected: selected, + contentPadding: contentPadding, + minLeadingWidth: kTrackThumbnailWidth, + leading: GpsTrackThumbnail(trackId: track.id), + title: Text(formatTrackTitle(units, track)), + subtitle: Text(formatTrackDetailLine(l10n, units, track)), + trailing: onDelete == null + ? null + : IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: l10n.common_action_delete, + onPressed: onDelete, + ), + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart b/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart new file mode 100644 index 0000000000..d2cbd0cdcc --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/gps_log/data/repositories/track_geometry_cache_repository.dart'; +import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; +import 'package:submersion/features/gps_log/presentation/providers/gps_track_map_providers.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/track_camera.dart'; +import 'package:submersion/features/maps/presentation/widgets/map_attribution.dart'; +import 'package:submersion/features/maps/presentation/widgets/map_compass_button.dart'; +import 'package:submersion/features/maps/presentation/widgets/map_interaction_options.dart'; +import 'package:submersion/features/maps/presentation/widgets/submersion_tile_layer.dart'; +import 'package:submersion/features/maps/presentation/widgets/trackpad_zoom_map.dart'; + +/// Selection section shared by every surface that pairs a track list with +/// the overview map, so a track picked on one is still picked on the other. +const String kGpsTrackSectionKey = 'gps-tracks'; + +/// Every given track on one map, the selected one drawn on top. +/// +/// Extracted from the overview map page so the GPS log page can host the same +/// map beside its list at desktop width without a second copy of the framing +/// logic. +class GpsTrackOverviewMap extends ConsumerStatefulWidget { + const GpsTrackOverviewMap({ + super.key, + required this.tracks, + required this.selectedId, + required this.controller, + }); + + final List tracks; + final String? selectedId; + final MapController controller; + + @override + ConsumerState createState() => + _GpsTrackOverviewMapState(); +} + +class _GpsTrackOverviewMapState extends ConsumerState { + bool _mapReady = false; + + /// Signature of the framing currently applied, so a filter change or a + /// late-arriving simplify re-frames but an unrelated rebuild does not. + String? _framedOn; + + List get tracks => widget.tracks; + String? get selectedId => widget.selectedId; + MapController get controller => widget.controller; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + // Unselected tracks are muted and drawn first; the selected one is drawn + // last with a thicker stroke so it sits on top of any it overlaps. + final unselected = >[]; + Polyline? selected; + List? selectedPoints; + final allPoints = []; + + for (final track in tracks) { + final geometry = + ref + .watch(gpsTrackGeometryProvider((track.id, TrackLod.thumbnail))) + .value ?? + const []; + if (geometry.length < 2) continue; + allPoints.addAll(geometry); + + final line = Polyline( + points: [for (final p in geometry) LatLng(p.latitude, p.longitude)], + color: track.id == selectedId ? scheme.primary : scheme.outline, + strokeWidth: track.id == selectedId ? 4.0 : 2.0, + strokeCap: StrokeCap.round, + hitValue: track.id, + ); + if (track.id == selectedId) { + selected = line; + selectedPoints = geometry; + } else { + unselected.add(line); + } + } + + // A selection frames that track alone; clearing it frames the library + // again. Same idea as the site map animating to the picked site. + final camera = TrackCamera.forPoints(selectedPoints ?? allPoints); + if (camera == null) { + return const SizedBox.shrink(); + } + + // Re-frame when the visible set changes: the date filter narrowing, a + // selection promoting a track, or a per-track simplify finishing. A + // FutureProvider reload keeps its previous value, so the map never + // unmounts and initialCameraFit would never apply again. + final signature = '${tracks.length}:${allPoints.length}:$selectedId'; + if (_mapReady && _framedOn != signature) { + _framedOn = signature; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) camera.applyTo(controller); + }); + } + + return TrackpadZoomMap( + controller: controller, + child: FlutterMap( + mapController: controller, + options: MapOptions( + onMapReady: () { + _mapReady = true; + _framedOn = signature; + }, + initialCameraFit: camera.fit, + initialCenter: camera.center ?? const LatLng(0, 0), + initialZoom: camera.zoom ?? 13.0, + interactionOptions: rotatableMapInteraction, + ), + children: [ + submersionTileLayer(ref), + PolylineLayer( + // Selected drawn last so it sits above any track it overlaps. + polylines: [...unselected, ?selected], + ), + const MapAttribution(), + MapCompassButton(controller: controller), + ], + ), + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/track_row_labels.dart b/lib/features/gps_log/presentation/widgets/track_row_labels.dart index 7518397ad7..2661110578 100644 --- a/lib/features/gps_log/presentation/widgets/track_row_labels.dart +++ b/lib/features/gps_log/presentation/widgets/track_row_labels.dart @@ -19,6 +19,13 @@ String formatTrackStart(UnitFormatter units, GpsTrack track) { return '${units.formatDate(start)} ${units.formatTime(start)}'; } +/// Compact duration, matching the app-wide dive_field_formatter style +/// ("Xh Ym" at an hour or more, "Xmin" below). +String formatCompactDuration(Duration duration) { + if (duration.inHours < 1) return '${duration.inMinutes}min'; + return '${duration.inHours}h ${duration.inMinutes % 60}m'; +} + /// Duration of the track as trimmed, compactly ("47min", "3h 12m"). /// /// The trim bounds are stored scalars, so this stays correct even though list @@ -26,9 +33,30 @@ String formatTrackStart(UnitFormatter units, GpsTrack track) { String formatTrackDuration(GpsTrack track) { final end = track.effectiveEndTime; if (end == null) return '--'; - final duration = Duration(milliseconds: end - track.effectiveStartTime); - if (duration.inHours < 1) return '${duration.inMinutes}min'; - return '${duration.inHours}h ${duration.inMinutes % 60}m'; + return formatCompactDuration( + Duration(milliseconds: end - track.effectiveStartTime), + ); +} + +/// The user's label when they gave one, else the start time. +String formatTrackTitle(UnitFormatter units, GpsTrack track) { + final name = track.name?.trim(); + if (name != null && name.isNotEmpty) return name; + return formatTrackStart(units, track); +} + +/// The line under [formatTrackTitle]: fixes and duration, led by the start +/// time when the title is a name and would otherwise hide it. +String formatTrackDetailLine( + AppLocalizations l10n, + UnitFormatter units, + GpsTrack track, +) { + final figures = formatTrackSubtitle(l10n, track, formatTrackDuration(track)); + final start = formatTrackStart(units, track); + return formatTrackTitle(units, track) == start + ? figures + : '$start • $figures'; } /// Fix count and duration, both honouring a trim. diff --git a/lib/features/gps_log/presentation/widgets/track_stat_tile.dart b/lib/features/gps_log/presentation/widgets/track_stat_tile.dart new file mode 100644 index 0000000000..2ab9dfb1b6 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/track_stat_tile.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// One labelled figure: a small muted label over a medium-weight value. +/// +/// Shared by the per-track stats header and the logger page's summary strip +/// so the two read as one system rather than two hand-rolled columns. +class TrackStatTile extends StatelessWidget { + const TrackStatTile({super.key, required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text(value, style: theme.textTheme.titleMedium), + ], + ); + } +} diff --git a/lib/features/gps_log/presentation/widgets/track_stats_header.dart b/lib/features/gps_log/presentation/widgets/track_stats_header.dart index e7776ab891..75d91c6f38 100644 --- a/lib/features/gps_log/presentation/widgets/track_stats_header.dart +++ b/lib/features/gps_log/presentation/widgets/track_stats_header.dart @@ -5,6 +5,7 @@ import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; import 'package:submersion/features/gps_log/domain/track_colorization.dart'; import 'package:submersion/features/gps_log/domain/track_geometry.dart'; +import 'package:submersion/features/gps_log/presentation/widgets/track_stat_tile.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -32,7 +33,6 @@ class TrackStatsHeader extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; - final theme = Theme.of(context); final units = UnitFormatter(ref.watch(settingsProvider)); final distance = trackDistanceMeters(points); @@ -63,20 +63,7 @@ class TrackStatsHeader extends ConsumerWidget { for (final (label, value) in tiles) Padding( padding: const EdgeInsets.only(right: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Text(value, style: theme.textTheme.titleMedium), - ], - ), + child: TrackStatTile(label: label, value: value), ), ], ), diff --git a/lib/shared/widgets/map_list_layout/map_list_scaffold.dart b/lib/shared/widgets/map_list_layout/map_list_scaffold.dart index 018406433c..d3e25f8029 100644 --- a/lib/shared/widgets/map_list_layout/map_list_scaffold.dart +++ b/lib/shared/widgets/map_list_layout/map_list_scaffold.dart @@ -16,7 +16,13 @@ class MapListScaffold extends ConsumerWidget { static const double _mobileInfoCardBottomOffset = 80; final String sectionKey; + + /// Plain title text; also feeds the pane accessibility labels. final String title; + + /// Rendered in the AppBar in place of a plain [title] when set, for pages + /// whose title carries a feature accent. + final Widget? titleWidget; final Widget listPane; final Widget mapPane; final Widget? infoCard; @@ -29,6 +35,7 @@ class MapListScaffold extends ConsumerWidget { super.key, required this.sectionKey, required this.title, + this.titleWidget, required this.listPane, required this.mapPane, this.infoCard, @@ -58,7 +65,7 @@ class MapListScaffold extends ConsumerWidget { // Mobile: Show only map with info card overlay return Scaffold( appBar: AppBar( - title: Semantics(header: true, child: Text(title)), + title: Semantics(header: true, child: titleWidget ?? Text(title)), leading: _buildLeadingButton(context), actions: actions, ), @@ -84,7 +91,7 @@ class MapListScaffold extends ConsumerWidget { // Desktop: Show list + map split return Scaffold( appBar: AppBar( - title: Semantics(header: true, child: Text(title)), + title: Semantics(header: true, child: titleWidget ?? Text(title)), leading: _buildLeadingButton(context), actions: [ // Expand button when collapsed diff --git a/test/features/gps_log/gps_log_summary_provider_test.dart b/test/features/gps_log/gps_log_summary_provider_test.dart new file mode 100644 index 0000000000..0f23a47a1a --- /dev/null +++ b/test/features/gps_log/gps_log_summary_provider_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; +import 'package:submersion/features/gps_log/presentation/providers/gps_log_providers.dart'; + +/// A completed track, wall-clock-as-UTC milliseconds like the repository +/// hands back. List rows are hydrated without points, so none are given. +GpsTrack _track( + String id, { + required DateTime start, + required DateTime end, + DateTime? trimStart, + DateTime? trimEnd, +}) => GpsTrack( + id: id, + startTime: start.millisecondsSinceEpoch, + endTime: end.millisecondsSinceEpoch, + pointCount: 2, + trimStartTime: trimStart?.millisecondsSinceEpoch, + trimEndTime: trimEnd?.millisecondsSinceEpoch, +); + +Dive _dive(String id, DateTime entry) => + Dive(id: id, diveNumber: 1, dateTime: entry, maxDepth: 20.0); + +ProviderContainer _container({ + required List tracks, + required List dives, +}) { + final container = ProviderContainer( + overrides: [ + gpsTracksProvider.overrideWith((ref) async => tracks), + divesProvider.overrideWith((ref) async => dives), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + final day = DateTime.utc(2026, 5, 22); + + test('an empty library reports zero everything', () async { + final container = _container(tracks: const [], dives: const []); + + final summary = await container.read(gpsLogSummaryProvider.future); + + expect(summary.trackCount, 0); + expect(summary.recordedTime, Duration.zero); + expect(summary.divesCovered, 0); + }); + + test('recorded time sums each track as trimmed', () async { + final container = _container( + tracks: [ + // Four hours recorded, trimmed to the two in the middle. + _track( + 'trimmed', + start: day.add(const Duration(hours: 8)), + end: day.add(const Duration(hours: 12)), + trimStart: day.add(const Duration(hours: 9)), + trimEnd: day.add(const Duration(hours: 11)), + ), + _track( + 'whole', + start: day.add(const Duration(hours: 14)), + end: day.add(const Duration(hours: 15, minutes: 30)), + ), + ], + dives: const [], + ); + + final summary = await container.read(gpsLogSummaryProvider.future); + + expect(summary.trackCount, 2); + expect(summary.recordedTime, const Duration(hours: 3, minutes: 30)); + }); + + test( + 'dives covered applies the matcher tolerance around the trimmed window', + () async { + final container = _container( + tracks: [ + _track( + 't', + start: day.add(const Duration(hours: 6)), + end: day.add(const Duration(hours: 12)), + // The drive to the marina is trimmed off: 06:00-08:00 no longer + // counts, so a 07:00 dive must not be covered. + trimStart: day.add(const Duration(hours: 8)), + ), + ], + dives: [ + _dive('inTrimmedLeg', day.add(const Duration(hours: 7))), + // 29 minutes before the trimmed start: inside the 30-min tolerance. + _dive('edge', day.add(const Duration(hours: 7, minutes: 31))), + _dive('inside', day.add(const Duration(hours: 10))), + // 31 minutes after the end: outside the tolerance. + _dive('late', day.add(const Duration(hours: 12, minutes: 31))), + ], + ); + + final summary = await container.read(gpsLogSummaryProvider.future); + + expect(summary.divesCovered, 2); + }, + ); + + test('a dive covered by two overlapping tracks is counted once', () async { + final container = _container( + tracks: [ + _track( + 'phone', + start: day.add(const Duration(hours: 8)), + end: day.add(const Duration(hours: 12)), + ), + _track( + 'watch', + start: day.add(const Duration(hours: 9)), + end: day.add(const Duration(hours: 11)), + ), + ], + dives: [_dive('d', day.add(const Duration(hours: 10)))], + ); + + final summary = await container.read(gpsLogSummaryProvider.future); + + expect(summary.divesCovered, 1); + }); +} diff --git a/test/features/gps_log/gps_logger_page_test.dart b/test/features/gps_log/gps_logger_page_test.dart index cf98a7aa0a..99e7ade5fa 100644 --- a/test/features/gps_log/gps_logger_page_test.dart +++ b/test/features/gps_log/gps_logger_page_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; @@ -9,14 +10,17 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; import 'package:submersion/features/gps_log/data/repositories/gps_track_repository.dart'; +import 'package:submersion/features/gps_log/data/repositories/track_geometry_cache_repository.dart'; import 'package:submersion/features/gps_log/data/services/gps_track_match_service.dart'; import 'package:submersion/features/gps_log/data/services/gps_track_recorder.dart'; import 'package:submersion/features/gps_log/domain/entities/gps_track.dart'; import 'package:submersion/features/gps_log/presentation/pages/gps_logger_page.dart'; import 'package:submersion/features/gps_log/presentation/providers/gps_log_providers.dart'; +import 'package:submersion/features/gps_log/presentation/providers/gps_track_map_providers.dart'; import 'package:submersion/features/gps_log/presentation/widgets/gps_track_thumbnail.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/shared/widgets/map_list_layout/map_info_card.dart'; import '../../helpers/test_database.dart'; @@ -115,6 +119,12 @@ void main() { GpsTrackRecorder? recorder, GpsTrackMatchService? matchService, Stream? recorderState, + // The page branches on MediaQuery width (>=1100 is the list + map split). + // Left null the test binding's default surface is a phone-class width. + Size? size, + // Overview-map geometry per track id; without it a track resolves to an + // empty polyline and the map draws nothing. + Map> geometry = const {}, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -150,16 +160,37 @@ void main() { gpsTrackMatchServiceProvider.overrideWithValue(matchService), if (recorderState != null) gpsRecorderStateProvider.overrideWith((ref) => recorderState), + for (final entry in geometry.entries) + gpsTrackGeometryProvider(( + entry.key, + TrackLod.thumbnail, + )).overrideWith((ref) async => entry.value), ], child: MaterialApp.router( routerConfig: router, locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + // Matching gps_track_map_page_test: the breakpoint reads MediaQuery, + // and setSurfaceSize alone does not update what the page sees. + builder: size == null + ? null + : (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(size: size), + child: child!, + ), ), ); } + /// Two fixes a few hundred metres apart: enough for the overview map to + /// frame a camera and draw a polyline. + const twoFixes = [ + GpsTrackPoint(timestamp: 1700000000, latitude: 1, longitude: 2), + GpsTrackPoint(timestamp: 1700000600, latitude: 1.003, longitude: 2.003), + ]; + const desktop = Size(1400, 900); + Future seedCompletedTrack() async { final id = await repo.startTrack( startTimeMs: 1700000000000, @@ -408,4 +439,149 @@ void main() { // The recovered track now renders as a completed tile. expect(find.byType(GpsTrackThumbnail), findsOneWidget); }); + + group('summary strip', () { + testWidgets('reports track count, recorded time and dives covered', ( + tester, + ) async { + await seedCompletedTrack(); + await tester.pumpWidget(await app()); + await tester.pumpAndSettle(); + + expect(find.text('Tracks'), findsOneWidget); + expect(find.text('Recorded time'), findsOneWidget); + expect(find.text('Dives covered'), findsOneWidget); + // The seeded track spans 1h 30m and no dive falls inside it. + expect(find.text('1h 30m'), findsOneWidget); + }); + + testWidgets('the empty state explains what the logger is for', ( + tester, + ) async { + await tester.pumpWidget(await app()); + await tester.pumpAndSettle(); + + expect(find.text('No GPS tracks recorded yet'), findsOneWidget); + expect( + find.text( + 'Record your position during a dive day and match imported dives ' + 'to GPS locations automatically.', + ), + findsOneWidget, + ); + }); + }); + + group('desktop split layout', () { + testWidgets('renders the track list beside the overview map', ( + tester, + ) async { + final id = await seedCompletedTrack(); + await tester.pumpWidget( + await app(size: desktop, geometry: {id: twoFixes}), + ); + await tester.pumpAndSettle(); + + // Only the overview map emits a PolylineLayer; thumbnails use + // the untyped layer. + expect(find.byType(PolylineLayer), findsOneWidget); + expect(find.text('Match dives to GPS logs'), findsOneWidget); + expect(find.byType(GpsTrackThumbnail), findsOneWidget); + // The map is already on screen, so the "Show map" action is redundant. + expect(find.byTooltip('Show map'), findsNothing); + }); + + testWidgets('a phone-width surface keeps the single column', ( + tester, + ) async { + final id = await seedCompletedTrack(); + await tester.pumpWidget( + await app(size: const Size(390, 844), geometry: {id: twoFixes}), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PolylineLayer), findsNothing); + expect(find.byTooltip('Show map'), findsOneWidget); + }); + + testWidgets('tapping a row selects it and shows an info card', ( + tester, + ) async { + final id = await seedCompletedTrack(); + await tester.pumpWidget( + await app(size: desktop, geometry: {id: twoFixes}), + ); + await tester.pumpAndSettle(); + expect(find.byType(MapInfoCard), findsNothing); + + await tester.tap(find.text('1 point, 1h 30m')); + await tester.pumpAndSettle(); + + expect(find.byType(MapInfoCard), findsOneWidget); + // Selected: drawn last, thicker. + final layer = tester.widget>( + find.byType(PolylineLayer), + ); + expect(layer.polylines.last.hitValue, id); + expect(layer.polylines.last.strokeWidth, 4.0); + // The row did not navigate away. + expect(find.text('TRACK-DETAIL-PAGE'), findsNothing); + }); + + testWidgets('the info card details action opens the track', (tester) async { + final id = await seedCompletedTrack(); + await tester.pumpWidget( + await app(size: desktop, geometry: {id: twoFixes}), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('1 point, 1h 30m')); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('View details')); + await tester.pumpAndSettle(); + + expect(find.text('TRACK-DETAIL-PAGE'), findsOneWidget); + }); + + testWidgets('the row delete action still confirms and removes', ( + tester, + ) async { + final id = await seedCompletedTrack(); + await tester.pumpWidget( + await app(size: desktop, geometry: {id: twoFixes}), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.pumpAndSettle(); + expect(find.text('Delete track?'), findsOneWidget); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(find.text('No GPS tracks recorded yet'), findsOneWidget); + expect(find.byType(PolylineLayer), findsNothing); + }); + + testWidgets('the empty state shows in the list pane and the map pane', ( + tester, + ) async { + await tester.pumpWidget(await app(size: desktop)); + await tester.pumpAndSettle(); + + expect(find.text('No GPS tracks recorded yet'), findsOneWidget); + expect(find.text('No recorded tracks to show.'), findsOneWidget); + // The pane is not left blank: an empty basemap sits behind the notice. + expect(find.byType(FlutterMap), findsOneWidget); + }); + + // A landscape iPad is wider than the split breakpoint and can record. + testWidgets('a tablet wide enough for the split still shows record ' + 'controls', (tester) async { + await tester.pumpWidget(await app(size: desktop)); + await tester.pumpAndSettle(); + + expect(find.byType(PolylineLayer), findsNothing); + expect(find.text('Start logging'), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + }); } diff --git a/test/features/gps_log/gps_track_map_page_test.dart b/test/features/gps_log/gps_track_map_page_test.dart index 2f08c50aeb..8b588d559f 100644 --- a/test/features/gps_log/gps_track_map_page_test.dart +++ b/test/features/gps_log/gps_track_map_page_test.dart @@ -91,10 +91,14 @@ void main() { expect(find.byType(FlutterMap), findsOneWidget); }); - testWidgets('shows an empty state when there are no tracks', (tester) async { + testWidgets('shows an empty basemap when there are no tracks', ( + tester, + ) async { await _pump(tester, tracks: const []); expect(find.text('No recorded tracks to show.'), findsOneWidget); - expect(find.byType(FlutterMap), findsNothing); + // A map still fills the pane; it just has nothing drawn on it. + expect(find.byType(FlutterMap), findsOneWidget); + expect(find.byType(PolylineLayer), findsNothing); }); testWidgets('selecting a track promotes it to a thicker stroke', ( @@ -118,6 +122,35 @@ void main() { expect(layer.polylines.first.strokeWidth, 2.0); }); + testWidgets('selecting a track frames the map on that track alone', ( + tester, + ) async { + await _pump(tester); + // Thumbnails are FlutterMaps too; only the overview map has a controller. + FlutterMap overview() => tester.widget( + find.byWidgetPredicate((w) => w is FlutterMap && w.mapController != null), + ); + double centreLat() => overview().mapController!.camera.center.latitude; + // Nothing selected: the whole library is framed, midway between t1 at + // 20 degrees and t2 at 25. + expect(centreLat(), closeTo(22.5, 1.0)); + + final container = ProviderScope.containerOf( + tester.element(find.byType(GpsTrackMapPage)), + ); + container + .read(mapListSelectionProvider('gps-tracks').notifier) + .select('t2'); + await tester.pumpAndSettle(); + + expect(centreLat(), closeTo(25.0, 0.1)); + + // Clearing the selection frames the library again. + container.read(mapListSelectionProvider('gps-tracks').notifier).deselect(); + await tester.pumpAndSettle(); + expect(centreLat(), closeTo(22.5, 1.0)); + }); + testWidgets('the date filter starts unbounded', (tester) async { await _pump(tester); expect(find.text('All dates'), findsOneWidget); From c17fc2cbd0f0cadffc675068a513e843c718720a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 16:43:33 -0400 Subject: [PATCH 115/122] i18n: translate the GPS log summary strip labels Adds gpsLogger_summary_tracks, gpsLogger_summary_recordedTime and gpsLogger_summary_divesCovered to all locales and regenerates the localization classes. --- lib/l10n/arb/app_ar.arb | 3 +++ lib/l10n/arb/app_de.arb | 3 +++ lib/l10n/arb/app_en.arb | 3 +++ lib/l10n/arb/app_es.arb | 3 +++ lib/l10n/arb/app_fr.arb | 3 +++ lib/l10n/arb/app_he.arb | 3 +++ lib/l10n/arb/app_hu.arb | 3 +++ lib/l10n/arb/app_it.arb | 3 +++ lib/l10n/arb/app_localizations.dart | 18 ++++++++++++++++++ lib/l10n/arb/app_localizations_ar.dart | 9 +++++++++ lib/l10n/arb/app_localizations_de.dart | 9 +++++++++ lib/l10n/arb/app_localizations_en.dart | 9 +++++++++ lib/l10n/arb/app_localizations_es.dart | 9 +++++++++ lib/l10n/arb/app_localizations_fr.dart | 9 +++++++++ lib/l10n/arb/app_localizations_he.dart | 9 +++++++++ lib/l10n/arb/app_localizations_hu.dart | 9 +++++++++ lib/l10n/arb/app_localizations_it.dart | 9 +++++++++ lib/l10n/arb/app_localizations_nl.dart | 9 +++++++++ lib/l10n/arb/app_localizations_pt.dart | 9 +++++++++ lib/l10n/arb/app_localizations_zh.dart | 9 +++++++++ lib/l10n/arb/app_nl.arb | 3 +++ lib/l10n/arb/app_pt.arb | 3 +++ lib/l10n/arb/app_zh.arb | 3 +++ 23 files changed, 150 insertions(+) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 1c3f175929..51115f6ed7 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3781,6 +3781,9 @@ "gpsLogger_startButton": "بدء التسجيل", "gpsLogger_stopButton": "إيقاف التسجيل", "gpsLogger_stripStatus": "جارٍ تسجيل مسار GPS · {count, plural, one{نقطة واحدة} two{نقطتان} few{{count} نقاط} other{{count} نقطة}}", + "gpsLogger_summary_tracks": "المسارات", + "gpsLogger_summary_recordedTime": "الوقت المسجّل", + "gpsLogger_summary_divesCovered": "الغطسات المغطاة", "gpsLogger_trackSubtitle": "{count, plural, one{نقطة واحدة} two{نقطتان} few{{count} نقاط} other{{count} نقطة}}، {duration}", "gpsLogger_trackSubtitleTrimmed": "مقتطع، {duration}", "gpsLogger_tracksHeader": "المسارات المسجّلة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index fe39c9a401..5293a89122 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3781,6 +3781,9 @@ "gpsLogger_startButton": "Aufzeichnung starten", "gpsLogger_stopButton": "Aufzeichnung beenden", "gpsLogger_stripStatus": "GPS-Track wird aufgezeichnet · {count, plural, one{{count} Punkt} other{{count} Punkte}}", + "gpsLogger_summary_tracks": "Tracks", + "gpsLogger_summary_recordedTime": "Aufgezeichnete Zeit", + "gpsLogger_summary_divesCovered": "Erfasste Tauchgänge", "gpsLogger_trackSubtitle": "{count, plural, one{{count} Punkt} other{{count} Punkte}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "Gekürzt, {duration}", "gpsLogger_tracksHeader": "Aufgezeichnete Tracks", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 396f3c1b17..fb215476a1 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -13747,6 +13747,9 @@ } } }, + "gpsLogger_summary_tracks": "Tracks", + "gpsLogger_summary_recordedTime": "Recorded time", + "gpsLogger_summary_divesCovered": "Dives covered", "gpsLogger_trackSubtitle": "{count, plural, one{{count} point} other{{count} points}}, {duration}", "@gpsLogger_trackSubtitle": { "placeholders": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index e74f461193..9e6ddb6265 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3781,6 +3781,9 @@ "gpsLogger_startButton": "Iniciar registro", "gpsLogger_stopButton": "Detener registro", "gpsLogger_stripStatus": "Grabando track GPS · {count, plural, one{{count} punto} other{{count} puntos}}", + "gpsLogger_summary_tracks": "Tracks", + "gpsLogger_summary_recordedTime": "Tiempo grabado", + "gpsLogger_summary_divesCovered": "Inmersiones cubiertas", "gpsLogger_trackSubtitle": "{count, plural, one{{count} punto} other{{count} puntos}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "Recortada, {duration}", "gpsLogger_tracksHeader": "Tracks grabados", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index c3326e65cc..f8e264140a 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3708,6 +3708,9 @@ "gpsLogger_startButton": "Démarrer l'enregistrement", "gpsLogger_stopButton": "Arrêter l'enregistrement", "gpsLogger_stripStatus": "Enregistrement du tracé GPS · {count, plural, one{{count} point} other{{count} points}}", + "gpsLogger_summary_tracks": "Traces", + "gpsLogger_summary_recordedTime": "Temps enregistré", + "gpsLogger_summary_divesCovered": "Plongées couvertes", "gpsLogger_trackSubtitle": "{count, plural, one{{count} point} other{{count} points}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "Rognée, {duration}", "gpsLogger_tracksHeader": "Traces enregistrées", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index e16bc02b50..687ad00a3c 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3708,6 +3708,9 @@ "gpsLogger_startButton": "התחל הקלטה", "gpsLogger_stopButton": "עצור הקלטה", "gpsLogger_stripStatus": "מקליט מסלול GPS · {count, plural, one{נקודה אחת} two{שתי נקודות} other{{count} נקודות}}", + "gpsLogger_summary_tracks": "מסלולים", + "gpsLogger_summary_recordedTime": "זמן מוקלט", + "gpsLogger_summary_divesCovered": "צלילות מכוסות", "gpsLogger_trackSubtitle": "{count, plural, one{נקודה אחת} two{שתי נקודות} other{{count} נקודות}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "נחתך, {duration}", "gpsLogger_tracksHeader": "מסלולים שהוקלטו", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index e958396cae..5e187211b7 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3708,6 +3708,9 @@ "gpsLogger_startButton": "Rögzítés indítása", "gpsLogger_stopButton": "Rögzítés leállítása", "gpsLogger_stripStatus": "GPS-útvonal rögzítése · {count, plural, one{{count} pont} other{{count} pont}}", + "gpsLogger_summary_tracks": "Útvonalak", + "gpsLogger_summary_recordedTime": "Rögzített idő", + "gpsLogger_summary_divesCovered": "Lefedett merülések", "gpsLogger_trackSubtitle": "{count, plural, one{{count} pont} other{{count} pont}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "Levágva, {duration}", "gpsLogger_tracksHeader": "Rögzített útvonalak", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index f01db141ff..7c6fd4c169 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3708,6 +3708,9 @@ "gpsLogger_startButton": "Avvia registrazione", "gpsLogger_stopButton": "Interrompi registrazione", "gpsLogger_stripStatus": "Registrazione traccia GPS · {count, plural, one{{count} punto} other{{count} punti}}", + "gpsLogger_summary_tracks": "Tracce", + "gpsLogger_summary_recordedTime": "Tempo registrato", + "gpsLogger_summary_divesCovered": "Immersioni coperte", "gpsLogger_trackSubtitle": "{count, plural, one{{count} punto} other{{count} punti}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "Ritagliata, {duration}", "gpsLogger_tracksHeader": "Tracce registrate", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index d0e24c8267..c5c5093652 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -35770,6 +35770,24 @@ abstract class AppLocalizations { /// **'Recording GPS track · {count, plural, one{{count} point} other{{count} points}}'** String gpsLogger_stripStatus(num count); + /// No description provided for @gpsLogger_summary_tracks. + /// + /// In en, this message translates to: + /// **'Tracks'** + String get gpsLogger_summary_tracks; + + /// No description provided for @gpsLogger_summary_recordedTime. + /// + /// In en, this message translates to: + /// **'Recorded time'** + String get gpsLogger_summary_recordedTime; + + /// No description provided for @gpsLogger_summary_divesCovered. + /// + /// In en, this message translates to: + /// **'Dives covered'** + String get gpsLogger_summary_divesCovered; + /// No description provided for @gpsLogger_trackSubtitle. /// /// 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 5934b0fbf0..5957bdc6c1 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -21132,6 +21132,15 @@ class AppLocalizationsAr extends AppLocalizations { return 'جارٍ تسجيل مسار GPS · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'المسارات'; + + @override + String get gpsLogger_summary_recordedTime => 'الوقت المسجّل'; + + @override + String get gpsLogger_summary_divesCovered => 'الغطسات المغطاة'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 722d4b0072..18a952fc29 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -21476,6 +21476,15 @@ class AppLocalizationsDe extends AppLocalizations { return 'GPS-Track wird aufgezeichnet · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Tracks'; + + @override + String get gpsLogger_summary_recordedTime => 'Aufgezeichnete Zeit'; + + @override + String get gpsLogger_summary_divesCovered => 'Erfasste Tauchgänge'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 68f362baf1..c668293aff 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -21150,6 +21150,15 @@ class AppLocalizationsEn extends AppLocalizations { return 'Recording GPS track · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Tracks'; + + @override + String get gpsLogger_summary_recordedTime => 'Recorded time'; + + @override + String get gpsLogger_summary_divesCovered => 'Dives covered'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 3d8afd03fb..7a0de14e8a 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -21532,6 +21532,15 @@ class AppLocalizationsEs extends AppLocalizations { return 'Grabando track GPS · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Tracks'; + + @override + String get gpsLogger_summary_recordedTime => 'Tiempo grabado'; + + @override + String get gpsLogger_summary_divesCovered => 'Inmersiones cubiertas'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 5cee3fe50b..6bc70b8b70 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -21589,6 +21589,15 @@ class AppLocalizationsFr extends AppLocalizations { return 'Enregistrement du tracé GPS · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Traces'; + + @override + String get gpsLogger_summary_recordedTime => 'Temps enregistré'; + + @override + String get gpsLogger_summary_divesCovered => 'Plongées couvertes'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index b441ed5692..82952085cc 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -20979,6 +20979,15 @@ class AppLocalizationsHe extends AppLocalizations { return 'מקליט מסלול GPS · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'מסלולים'; + + @override + String get gpsLogger_summary_recordedTime => 'זמן מוקלט'; + + @override + String get gpsLogger_summary_divesCovered => 'צלילות מכוסות'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index d5b2ae2092..b01d9fd44a 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -21446,6 +21446,15 @@ class AppLocalizationsHu extends AppLocalizations { return 'GPS-útvonal rögzítése · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Útvonalak'; + + @override + String get gpsLogger_summary_recordedTime => 'Rögzített idő'; + + @override + String get gpsLogger_summary_divesCovered => 'Lefedett merülések'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 8f91fe491c..6576e3ba23 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -21512,6 +21512,15 @@ class AppLocalizationsIt extends AppLocalizations { return 'Registrazione traccia GPS · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Tracce'; + + @override + String get gpsLogger_summary_recordedTime => 'Tempo registrato'; + + @override + String get gpsLogger_summary_divesCovered => 'Immersioni coperte'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 972a1379b5..af66915139 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -21345,6 +21345,15 @@ class AppLocalizationsNl extends AppLocalizations { return 'GPS-track wordt opgenomen · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Tracks'; + + @override + String get gpsLogger_summary_recordedTime => 'Opgenomen tijd'; + + @override + String get gpsLogger_summary_divesCovered => 'Gedekte duiken'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index c831f10142..d4104ff007 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -21514,6 +21514,15 @@ class AppLocalizationsPt extends AppLocalizations { return 'Gravando trilha GPS · $_temp0'; } + @override + String get gpsLogger_summary_tracks => 'Trilhas'; + + @override + String get gpsLogger_summary_recordedTime => 'Tempo gravado'; + + @override + String get gpsLogger_summary_divesCovered => 'Mergulhos cobertos'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index e50cf9e8ba..758633a51e 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -20428,6 +20428,15 @@ class AppLocalizationsZh extends AppLocalizations { return '正在记录 GPS 轨迹 · $_temp0'; } + @override + String get gpsLogger_summary_tracks => '轨迹'; + + @override + String get gpsLogger_summary_recordedTime => '记录时长'; + + @override + String get gpsLogger_summary_divesCovered => '覆盖的潜水'; + @override String gpsLogger_trackSubtitle(num count, String duration) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 7226531dca..409dae016f 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3781,6 +3781,9 @@ "gpsLogger_startButton": "Opname starten", "gpsLogger_stopButton": "Opname stoppen", "gpsLogger_stripStatus": "GPS-track wordt opgenomen · {count, plural, one{{count} punt} other{{count} punten}}", + "gpsLogger_summary_tracks": "Tracks", + "gpsLogger_summary_recordedTime": "Opgenomen tijd", + "gpsLogger_summary_divesCovered": "Gedekte duiken", "gpsLogger_trackSubtitle": "{count, plural, one{{count} punt} other{{count} punten}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "Bijgesneden, {duration}", "gpsLogger_tracksHeader": "Opgenomen tracks", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 7e7590ee01..baceda7366 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3781,6 +3781,9 @@ "gpsLogger_startButton": "Iniciar registro", "gpsLogger_stopButton": "Parar registro", "gpsLogger_stripStatus": "Gravando trilha GPS · {count, plural, one{{count} ponto} other{{count} pontos}}", + "gpsLogger_summary_tracks": "Trilhas", + "gpsLogger_summary_recordedTime": "Tempo gravado", + "gpsLogger_summary_divesCovered": "Mergulhos cobertos", "gpsLogger_trackSubtitle": "{count, plural, one{{count} ponto} other{{count} pontos}}, {duration}", "gpsLogger_trackSubtitleTrimmed": "Recortada, {duration}", "gpsLogger_tracksHeader": "Trilhas gravadas", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index fdfb714720..50380eb224 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3926,6 +3926,9 @@ "gpsLogger_startButton": "开始记录", "gpsLogger_stopButton": "停止记录", "gpsLogger_stripStatus": "正在记录 GPS 轨迹 · {count, plural, other{{count} 个点}}", + "gpsLogger_summary_tracks": "轨迹", + "gpsLogger_summary_recordedTime": "记录时长", + "gpsLogger_summary_divesCovered": "覆盖的潜水", "gpsLogger_trackSubtitle": "{count, plural, other{{count} 个点}},{duration}", "gpsLogger_trackSubtitleTrimmed": "已裁剪,{duration}", "gpsLogger_tracksHeader": "已记录的轨迹", From eee4dffce7732aa4ebb91da64bae94f9400fce73 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 16:54:44 -0400 Subject: [PATCH 116/122] fix(settings): restore the diacritics in the native language names Espanol, Francais and Portugues were stored with their diacritics stripped, while Deutsch, Magyar and the Chinese, Arabic and Hebrew entries were not. The list is now shown in the place name language picker as well as the app language page, so the misspellings are twice as visible. --- .../plans/2026-08-26-site-location-from-coordinates.md | 2 +- .../settings/presentation/pages/language_settings_page.dart | 6 +++--- .../widgets/place_name_language_picker_test.dart | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md b/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md index 1c2e30d7b3..30d4ea220a 100644 --- a/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md +++ b/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md @@ -1502,7 +1502,7 @@ void main() { tester, ) async { await openPicker(tester); - for (final name in ['English', 'Deutsch', 'Espanol', 'Magyar', '简体中文']) { + for (final name in ['English', 'Deutsch', 'Español', 'Magyar', '简体中文']) { expect(find.text(name), findsOneWidget, reason: 'missing $name'); } expect(find.text('System Default'), findsNothing); diff --git a/lib/features/settings/presentation/pages/language_settings_page.dart b/lib/features/settings/presentation/pages/language_settings_page.dart index 320eafcdd7..373be589db 100644 --- a/lib/features/settings/presentation/pages/language_settings_page.dart +++ b/lib/features/settings/presentation/pages/language_settings_page.dart @@ -11,14 +11,14 @@ class LanguageSettingsPage extends ConsumerWidget { static const supportedLocales = [ LocaleOption(code: 'system', nativeName: 'System Default', englishName: ''), LocaleOption(code: 'en', nativeName: 'English', englishName: 'English'), - LocaleOption(code: 'es', nativeName: 'Espanol', englishName: 'Spanish'), - LocaleOption(code: 'fr', nativeName: 'Francais', englishName: 'French'), + LocaleOption(code: 'es', nativeName: 'Español', englishName: 'Spanish'), + LocaleOption(code: 'fr', nativeName: 'Français', englishName: 'French'), LocaleOption(code: 'de', nativeName: 'Deutsch', englishName: 'German'), LocaleOption(code: 'it', nativeName: 'Italiano', englishName: 'Italian'), LocaleOption(code: 'nl', nativeName: 'Nederlands', englishName: 'Dutch'), LocaleOption( code: 'pt', - nativeName: 'Portugues', + nativeName: 'Português', englishName: 'Portuguese', ), LocaleOption(code: 'hu', nativeName: 'Magyar', englishName: 'Hungarian'), diff --git a/test/features/settings/presentation/widgets/place_name_language_picker_test.dart b/test/features/settings/presentation/widgets/place_name_language_picker_test.dart index 914790cb01..eb8ecc1a2d 100644 --- a/test/features/settings/presentation/widgets/place_name_language_picker_test.dart +++ b/test/features/settings/presentation/widgets/place_name_language_picker_test.dart @@ -72,7 +72,7 @@ void main() { tester, ) async { await openPicker(tester); - for (final name in ['English', 'Deutsch', 'Espanol', 'Magyar', '简体中文']) { + for (final name in ['English', 'Deutsch', 'Español', 'Magyar', '简体中文']) { expect(find.text(name), findsOneWidget, reason: 'missing $name'); } expect(find.text('System Default'), findsNothing); From 4cb6e62ba10cb82f8708eaaee6ae1320b82cf2d3 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 17:00:45 -0400 Subject: [PATCH 117/122] fix(buddies): correct picker sort direction, guard setFavorite, drop claude.yml Addresses the three Copilot review findings on PR #1237. Sort toggle direction: text fields invert direction throughout this codebase, so SortDirection.descending is what renders A to Z (buddySortProvider on the standalone buddy list already defaults to name + descending for that reason). The picker's new toggle asked for ascending, which landed on the inverted branch and rendered Z to A. Three widget tests now pin the rendered order for the default sort, the toggled sort, and the toggle back. setFavorite phantom sync records: the update wrote unconditionally and then marked the record pending even when no row matched, leaving a sync record pointing at a buddy that does not exist. Drift's write() returns the affected row count, so the method now returns early on zero. toggleFavorite already guarded this with its read-before-write; both paths now have a regression test asserting sync_records stays empty for an unknown id. Removed .github/workflows/claude.yml: an issue_comment-triggered job with contents: write and pull-requests: write, gated only on the comment body containing "@claude", lets any commenter drive privileged automation with the base repo's secrets. --- .github/workflows/claude.yml | 42 ----------- .../data/repositories/buddy_repository.dart | 18 ++++- .../presentation/widgets/buddy_picker.dart | 5 +- .../repositories/buddy_repository_test.dart | 24 ++++++- .../widgets/buddy_picker_test.dart | 70 +++++++++++++++++++ 5 files changed, 112 insertions(+), 47 deletions(-) delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 83c427616c..0000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Claude Code -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] -jobs: - claude: - if: contains(github.event.comment.body, '@claude') - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: true - - - name: Read Flutter version - id: flutter-ver - run: echo "version=$(cat .github/flutter-version.txt)" >> "$GITHUB_OUTPUT" - - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ steps.flutter-ver.outputs.version }} - channel: 'stable' - - - name: Install dependencies - run: flutter pub get - - - name: Run code generation - run: dart run build_runner build --delete-conflicting-outputs - - - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: | - --allowedTools "Bash(flutter pub get:*),Bash(flutter analyze:*),Bash(flutter test:*),Bash(dart format:*),Bash(git fetch:*),Bash(git merge:*),Bash(git rebase:*),Bash(git push:*),Bash(gh pr:*),Bash(gh issue comment:*)" diff --git a/lib/features/buddies/data/repositories/buddy_repository.dart b/lib/features/buddies/data/repositories/buddy_repository.dart index 5a4b4852f6..4d8ff761f5 100644 --- a/lib/features/buddies/data/repositories/buddy_repository.dart +++ b/lib/features/buddies/data/repositories/buddy_repository.dart @@ -879,9 +879,21 @@ class BuddyRepository { try { _log.info('Setting favorite=$isFavorite for buddy: $buddyId'); final now = DateTime.now().millisecondsSinceEpoch; - await (_db.update(_db.buddies)..where((t) => t.id.equals(buddyId))).write( - BuddiesCompanion(isFavorite: Value(isFavorite), updatedAt: Value(now)), - ); + final updated = + await (_db.update( + _db.buddies, + )..where((t) => t.id.equals(buddyId))).write( + BuddiesCompanion( + isFavorite: Value(isFavorite), + updatedAt: Value(now), + ), + ); + // A stale or deleted buddyId updates nothing; marking it pending would + // leave a sync record pointing at a row that does not exist. + if (updated == 0) { + _log.info('No buddy matched id, skipping favorite update: $buddyId'); + return; + } await _syncRepository.markRecordPending( entityType: 'buddies', recordId: buddyId, diff --git a/lib/features/buddies/presentation/widgets/buddy_picker.dart b/lib/features/buddies/presentation/widgets/buddy_picker.dart index 241c121b6b..3b6b6070e4 100644 --- a/lib/features/buddies/presentation/widgets/buddy_picker.dart +++ b/lib/features/buddies/presentation/widgets/buddy_picker.dart @@ -463,9 +463,12 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { child: TextButton.icon( onPressed: () { final next = sort.field == BuddySortField.diveCount + // Text fields invert direction throughout this + // codebase: descending is what renders A->Z. Ascending + // here would flip the alphabetical toggle to Z->A. ? const SortState( field: BuddySortField.name, - direction: SortDirection.ascending, + direction: SortDirection.descending, ) : const SortState( field: BuddySortField.diveCount, diff --git a/test/features/buddies/data/repositories/buddy_repository_test.dart b/test/features/buddies/data/repositories/buddy_repository_test.dart index c470a76a3d..6c2c9255a0 100644 --- a/test/features/buddies/data/repositories/buddy_repository_test.dart +++ b/test/features/buddies/data/repositories/buddy_repository_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart' show AppDatabase; import 'package:submersion/core/services/database_service.dart'; import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; import 'package:submersion/features/buddies/domain/entities/buddy.dart'; @@ -10,9 +11,10 @@ import '../../../../helpers/test_database.dart'; void main() { late BuddyRepository repository; + late AppDatabase db; setUp(() async { - await setUpTestDatabase(); + db = await setUpTestDatabase(); repository = BuddyRepository(); }); @@ -337,6 +339,26 @@ void main() { await repository.setFavorite(buddy.id, false); expect((await repository.getBuddyById(buddy.id))!.isFavorite, isFalse); }); + + test( + 'setFavorite on an unknown id leaves no pending sync record', + () async { + await repository.setFavorite('does-not-exist', true); + + final pending = await db.select(db.syncRecords).get(); + expect(pending, isEmpty); + }, + ); + + test( + 'toggleFavorite on an unknown id leaves no pending sync record', + () async { + await repository.toggleFavorite('does-not-exist'); + + final pending = await db.select(db.syncRecords).get(); + expect(pending, isEmpty); + }, + ); }); group('getBuddyStats', () { diff --git a/test/features/buddies/presentation/widgets/buddy_picker_test.dart b/test/features/buddies/presentation/widgets/buddy_picker_test.dart index 61b1967c1a..256b1a2ae5 100644 --- a/test/features/buddies/presentation/widgets/buddy_picker_test.dart +++ b/test/features/buddies/presentation/widgets/buddy_picker_test.dart @@ -514,4 +514,74 @@ void main() { expect(result![0].role.id, equals(DiveRole.instructorId)); }); }); + + group('BuddyPicker - sort toggle (issue #638)', () { + // Dive counts deliberately disagree with alphabetical order so the two + // sorts are distinguishable: by count it reads Charlie, Bob, Alice. + final rankedBuddies = [ + BuddyWithDiveCount(buddy: _testBuddies[0], diveCount: 1), // Alice + BuddyWithDiveCount(buddy: _testBuddies[1], diveCount: 5), // Bob + BuddyWithDiveCount(buddy: _testBuddies[2], diveCount: 9), // Charlie + ]; + + /// The buddy names as the sheet actually renders them, top to bottom. + List renderedNames(WidgetTester tester) => [ + for (final tile in tester.widgetList(find.byType(ListTile))) + (tile.title! as Text).data!, + ]; + + Future pumpSheet(WidgetTester tester) async { + _useTallScreen(tester); + await tester.pumpWidget( + _buildPicker( + overrides: [ + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => rankedBuddies, + ), + ], + ), + ); + await tester.pumpAndSettle(); + await _openSheet(tester); + } + + testWidgets('defaults to dive count, most dives first', (tester) async { + await pumpSheet(tester); + + expect(renderedNames(tester), [ + 'Charlie Brown', + 'Bob Jones', + 'Alice Smith', + ]); + }); + + testWidgets('toggling to name sorts A to Z, not Z to A', (tester) async { + await pumpSheet(tester); + + await tester.tap(find.widgetWithText(TextButton, 'Sort: Dive Count')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextButton, 'Sort: Name'), findsOneWidget); + expect(renderedNames(tester), [ + 'Alice Smith', + 'Bob Jones', + 'Charlie Brown', + ]); + }); + + testWidgets('toggling back restores the dive count order', (tester) async { + await pumpSheet(tester); + + await tester.tap(find.widgetWithText(TextButton, 'Sort: Dive Count')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Sort: Name')); + await tester.pumpAndSettle(); + + expect(renderedNames(tester), [ + 'Charlie Brown', + 'Bob Jones', + 'Alice Smith', + ]); + }); + }); } From 711b50c5eb0045cc979db8171b0efd0d99ff8ce9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 17:45:03 -0400 Subject: [PATCH 118/122] fix(gps-log): keep the overview basemap mounted while geometry loads GpsTrackOverviewMap returned nothing until at least one track had two decoded fixes, so on a cold cache the map pane was blank for as long as the decode and simplify took. The basemap now mounts at a world view straight away and frames the tracks when their geometry lands, from onMapReady if it arrived before the map was ready and from the signature path otherwise. --- .../widgets/gps_track_overview_map.dart | 26 +++++++++------ .../gps_log/gps_track_map_page_test.dart | 32 ++++++++++++++++++- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart b/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart index d2cbd0cdcc..7b3df56e20 100644 --- a/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart +++ b/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart @@ -87,10 +87,12 @@ class _GpsTrackOverviewMapState extends ConsumerState { // A selection frames that track alone; clearing it frames the library // again. Same idea as the site map animating to the picked site. + // + // Null while nothing can be framed yet: a cold cache is still decoding + // and simplifying every track, or no track has two fixes. The basemap + // stays mounted at a world view rather than the pane going blank, and + // the framing below catches up when geometry lands. final camera = TrackCamera.forPoints(selectedPoints ?? allPoints); - if (camera == null) { - return const SizedBox.shrink(); - } // Re-frame when the visible set changes: the date filter narrowing, a // selection promoting a track, or a per-track simplify finishing. A @@ -99,9 +101,11 @@ class _GpsTrackOverviewMapState extends ConsumerState { final signature = '${tracks.length}:${allPoints.length}:$selectedId'; if (_mapReady && _framedOn != signature) { _framedOn = signature; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) camera.applyTo(controller); - }); + if (camera != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) camera.applyTo(controller); + }); + } } return TrackpadZoomMap( @@ -112,10 +116,14 @@ class _GpsTrackOverviewMapState extends ConsumerState { onMapReady: () { _mapReady = true; _framedOn = signature; + // Geometry that arrived between the first build and the map + // becoming ready would otherwise never be framed: the signature + // path above only fires while _mapReady is already true. + camera?.applyTo(controller); }, - initialCameraFit: camera.fit, - initialCenter: camera.center ?? const LatLng(0, 0), - initialZoom: camera.zoom ?? 13.0, + initialCameraFit: camera?.fit, + initialCenter: camera?.center ?? const LatLng(20, 0), + initialZoom: camera?.zoom ?? 2.0, interactionOptions: rotatableMapInteraction, ), children: [ diff --git a/test/features/gps_log/gps_track_map_page_test.dart b/test/features/gps_log/gps_track_map_page_test.dart index 8b588d559f..dbba219731 100644 --- a/test/features/gps_log/gps_track_map_page_test.dart +++ b/test/features/gps_log/gps_track_map_page_test.dart @@ -32,9 +32,12 @@ Future _pump( WidgetTester tester, { Size size = const Size(1400, 900), List? tracks, + // Thumbnail-LOD geometry per track; defaults to the track's own points. + Future> Function(GpsTrack track)? geometry, }) async { final base = await getBaseOverrides(); final data = tracks ?? [_track('t1', 20.0), _track('t2', 25.0)]; + final resolve = geometry ?? (t) async => t.points; await tester.binding.setSurfaceSize(size); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -47,7 +50,7 @@ Future _pump( gpsTrackGeometryProvider(( t.id, TrackLod.thumbnail, - )).overrideWith((ref) async => t.points), + )).overrideWith((ref) => resolve(t)), ], child: MaterialApp( locale: const Locale('en'), @@ -151,6 +154,33 @@ void main() { expect(centreLat(), closeTo(22.5, 1.0)); }); + testWidgets('mounts the basemap before geometry arrives and frames once it ' + 'does', (tester) async { + // A cold cache decodes and simplifies every track in an isolate; until + // the first one lands there is nothing to frame. + final pending = Completer>(); + final track = _track('t2', 25.0); + await _pump(tester, tracks: [track], geometry: (_) => pending.future); + + FlutterMap overview() => tester.widget( + find.byWidgetPredicate((w) => w is FlutterMap && w.mapController != null), + ); + // The map is already on screen, drawing nothing yet. + expect(overview, returnsNormally); + final layer = tester.widget>( + find.byType(PolylineLayer), + ); + expect(layer.polylines, isEmpty); + + pending.complete(track.points); + await tester.pumpAndSettle(); + + expect( + overview().mapController!.camera.center.latitude, + closeTo(25.0, 0.1), + ); + }); + testWidgets('the date filter starts unbounded', (tester) async { await _pump(tester); expect(find.text('All dates'), findsOneWidget); From 892852a5e644f3e1c960c81801c93b1263803e34 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 17:52:01 -0400 Subject: [PATCH 119/122] fix(statistics): pair the deco legend labels with their bar segments The Decompression Obligation bar sets `value` to the deco share, so it paints orange from the leading edge and leaves the no-deco remainder in green. The legend below it listed green "No Deco" first and orange "Deco" last, putting each word at the opposite end from the segment it named. Swap the two labels so the orange one leads with the orange fill. Row and LinearProgressIndicator are both direction-aware, so the pairing holds in RTL locales. The bar's Semantics label was already correct and is unchanged. Adds a widget test that derives the invariant rather than hardcoding a side: it reads valueColor off the rendered indicator and asserts the legend label carrying that color sits at the leading edge. --- .../pages/statistics_profile_page.dart | 13 +- .../statistics_profile_deco_legend_test.dart | 122 ++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 test/features/statistics/presentation/pages/statistics_profile_deco_legend_test.dart diff --git a/lib/features/statistics/presentation/pages/statistics_profile_page.dart b/lib/features/statistics/presentation/pages/statistics_profile_page.dart index bca39a4f43..4aac804d61 100644 --- a/lib/features/statistics/presentation/pages/statistics_profile_page.dart +++ b/lib/features/statistics/presentation/pages/statistics_profile_page.dart @@ -266,21 +266,26 @@ class StatisticsProfilePage extends ConsumerWidget { ), ), const SizedBox(height: 8), + // The bar paints the deco share in orange from the leading edge + // and leaves the no-deco remainder in green, so the legend has + // to name them in that order. Row and LinearProgressIndicator + // are both direction-aware, which keeps the pairing intact in + // RTL locales. ExcludeSemantics( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - context.l10n.statistics_profile_deco_noDeco, + context.l10n.statistics_profile_deco_decoLabel, style: Theme.of( context, - ).textTheme.bodySmall?.copyWith(color: Colors.green), + ).textTheme.bodySmall?.copyWith(color: Colors.orange), ), Text( - context.l10n.statistics_profile_deco_decoLabel, + context.l10n.statistics_profile_deco_noDeco, style: Theme.of( context, - ).textTheme.bodySmall?.copyWith(color: Colors.orange), + ).textTheme.bodySmall?.copyWith(color: Colors.green), ), ], ), diff --git a/test/features/statistics/presentation/pages/statistics_profile_deco_legend_test.dart b/test/features/statistics/presentation/pages/statistics_profile_deco_legend_test.dart new file mode 100644 index 0000000000..de0604e753 --- /dev/null +++ b/test/features/statistics/presentation/pages/statistics_profile_deco_legend_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/statistics/presentation/pages/statistics_profile_page.dart'; +import 'package:submersion/features/statistics/presentation/providers/statistics_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +/// Minimal mock SettingsNotifier using noSuchMethod to avoid re-implementing +/// the full interface. Matches the pattern used in the other statistics page +/// tests. +class _MockSettingsNotifier extends StateNotifier + implements SettingsNotifier { + _MockSettingsNotifier() : super(const AppSettings()); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Mock CurrentDiverIdNotifier that does not access the database. +class _MockCurrentDiverIdNotifier extends StateNotifier + implements CurrentDiverIdNotifier { + _MockCurrentDiverIdNotifier() : super(null); + + @override + Future setCurrentDiver(String id) async => state = id; + + @override + Future clearCurrentDiver() async => state = null; +} + +void main() { + group('Decompression Obligation legend', () { + const decoCount = 29; + const noDecoCount = 194; + + late SharedPreferences prefs; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + Future pumpPage(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + decoObligationStatsProvider.overrideWith( + (ref) async => ( + decoCount: decoCount, + noDecoCount: noDecoCount, + unknownCount: 0, + ), + ), + ascentDescentRatesProvider.overrideWith( + (ref) async => (avgAscent: null, avgDescent: null), + ), + timeAtDepthRangesProvider.overrideWith((ref) async => []), + sharedPreferencesProvider.overrideWithValue(prefs), + settingsProvider.overrideWith((ref) => _MockSettingsNotifier()), + currentDiverIdProvider.overrideWith( + (ref) => _MockCurrentDiverIdNotifier(), + ), + ], + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: StatisticsProfilePage(embedded: true), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// The legend labels are the only colored copies of these strings; the + /// stat tiles above render the same words in onSurfaceVariant. + Finder legendLabel(String text, Color color) => find.byWidgetPredicate( + (widget) => + widget is Text && widget.data == text && widget.style?.color == color, + description: '$text label styled $color', + ); + + testWidgets('bar fills with the deco share', (tester) async { + await pumpPage(tester); + + final bar = tester.widget( + find.byType(LinearProgressIndicator), + ); + + expect(bar.value, closeTo(decoCount / (decoCount + noDecoCount), 0.001)); + expect(bar.valueColor?.value, Colors.orange); + }); + + testWidgets('each label sits under the segment it names', (tester) async { + await pumpPage(tester); + + final bar = tester.widget( + find.byType(LinearProgressIndicator), + ); + // The bar paints valueColor from the leading edge and leaves the + // remainder in backgroundColor, so the label naming the filled share + // has to be the leading one. + final fillColor = bar.valueColor!.value!; + + final decoFinder = legendLabel('Deco', fillColor); + final noDecoFinder = legendLabel('No Deco', Colors.green); + expect(decoFinder, findsOneWidget); + expect(noDecoFinder, findsOneWidget); + + expect( + tester.getCenter(decoFinder).dx, + lessThan(tester.getCenter(noDecoFinder).dx), + reason: + 'the orange "Deco" label must sit under the orange leading fill, ' + 'not opposite it', + ); + }); + }); +} From 5b708d99fd0728cc9a56a8bb8a95f7665b9a63c9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 17:53:21 -0400 Subject: [PATCH 120/122] i18n: add a generic Any label for the Advanced Search deco chips The deco "Any" chip borrowed diveSites_filter_difficulty_any, which is translated in the context of a difficulty scale (Arabic renders it "any level"). Add a dedicated diveLog_search_filter_any so the Advanced Search tri-state chips have a home of their own, translated in all ten non-English locales with the values already shipping for the app's other generic filter "Any", and regenerate app_localizations_*.dart. --- .../dive_log/presentation/pages/dive_search_page.dart | 2 +- lib/l10n/arb/app_ar.arb | 1 + lib/l10n/arb/app_de.arb | 1 + lib/l10n/arb/app_en.arb | 1 + lib/l10n/arb/app_es.arb | 1 + lib/l10n/arb/app_fr.arb | 1 + lib/l10n/arb/app_he.arb | 1 + lib/l10n/arb/app_hu.arb | 1 + lib/l10n/arb/app_it.arb | 1 + lib/l10n/arb/app_localizations.dart | 6 ++++++ lib/l10n/arb/app_localizations_ar.dart | 3 +++ lib/l10n/arb/app_localizations_de.dart | 3 +++ lib/l10n/arb/app_localizations_en.dart | 3 +++ lib/l10n/arb/app_localizations_es.dart | 3 +++ lib/l10n/arb/app_localizations_fr.dart | 3 +++ lib/l10n/arb/app_localizations_he.dart | 3 +++ lib/l10n/arb/app_localizations_hu.dart | 3 +++ lib/l10n/arb/app_localizations_it.dart | 3 +++ lib/l10n/arb/app_localizations_nl.dart | 3 +++ lib/l10n/arb/app_localizations_pt.dart | 3 +++ lib/l10n/arb/app_localizations_zh.dart | 3 +++ lib/l10n/arb/app_nl.arb | 1 + lib/l10n/arb/app_pt.arb | 1 + lib/l10n/arb/app_zh.arb | 1 + 24 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/features/dive_log/presentation/pages/dive_search_page.dart b/lib/features/dive_log/presentation/pages/dive_search_page.dart index d3cc7e7dcb..24155272ea 100644 --- a/lib/features/dive_log/presentation/pages/dive_search_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_search_page.dart @@ -557,7 +557,7 @@ class _DiveSearchPageState extends ConsumerState { runSpacing: 8, children: [ ChoiceChip( - label: Text(context.l10n.diveSites_filter_difficulty_any), + label: Text(context.l10n.diveLog_search_filter_any), selected: _decoOnly == null, onSelected: (selected) { if (selected) setState(() => _decoOnly = null); diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index d4c4d02be4..8fc738973b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2363,6 +2363,7 @@ "diveLog_search_errorLoadingCenters": "خطأ في تحميل مراكز الغوص", "diveLog_search_errorLoadingDiveTypes": "خطأ في تحميل أنواع الغوص", "diveLog_search_errorLoadingTrips": "خطأ في تحميل الرحلات", + "diveLog_search_filter_any": "أي", "diveLog_search_gasTrimix": "ترايمكس (<21% O₂)", "diveLog_search_label_deco": "تخفيف الضغط", "diveLog_search_label_depthRange": "نطاق العمق (m)", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 7b86b59901..9c33d5922d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -2363,6 +2363,7 @@ "diveLog_search_errorLoadingCenters": "Fehler beim Laden der Tauchbasen", "diveLog_search_errorLoadingDiveTypes": "Fehler beim Laden der Tauchgangstypen", "diveLog_search_errorLoadingTrips": "Fehler beim Laden der Reisen", + "diveLog_search_filter_any": "Beliebig", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Dekompression", "diveLog_search_label_depthRange": "Tiefenbereich (m)", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 48f6894b3b..8b930cd299 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3791,6 +3791,7 @@ "diveLog_search_errorLoadingCenters": "Error loading dive centers", "diveLog_search_errorLoadingDiveTypes": "Error loading dive types", "diveLog_search_errorLoadingTrips": "Error loading trips", + "diveLog_search_filter_any": "Any", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Decompression", "diveLog_search_label_depthRange": "Depth Range (m)", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 5f6afac3c0..ac73d1330f 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2363,6 +2363,7 @@ "diveLog_search_errorLoadingCenters": "Error al cargar los centros de buceo", "diveLog_search_errorLoadingDiveTypes": "Error al cargar tipos de inmersión", "diveLog_search_errorLoadingTrips": "Error al cargar los viajes", + "diveLog_search_filter_any": "Cualquiera", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Descompresión", "diveLog_search_label_depthRange": "Rango de profundidad (m)", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index a312ac58c8..be3315b8e3 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -2290,6 +2290,7 @@ "diveLog_search_errorLoadingCenters": "Erreur de chargement des centres de plongee", "diveLog_search_errorLoadingDiveTypes": "Erreur lors du chargement des types de plongée", "diveLog_search_errorLoadingTrips": "Erreur de chargement des voyages", + "diveLog_search_filter_any": "Indifférent", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Décompression", "diveLog_search_label_depthRange": "Plage de profondeur (m)", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 65bd57f859..77aa970c20 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -2290,6 +2290,7 @@ "diveLog_search_errorLoadingCenters": "שגיאה בטעינת מרכזי צלילה", "diveLog_search_errorLoadingDiveTypes": "שגיאה בטעינת סוגי צלילה", "diveLog_search_errorLoadingTrips": "שגיאה בטעינת טיולים", + "diveLog_search_filter_any": "הכול", "diveLog_search_gasTrimix": "טריימיקס (<21% O₂)", "diveLog_search_label_deco": "דקומפרסיה", "diveLog_search_label_depthRange": "טווח עומק (m)", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index c482135d14..9cb5fc1daf 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -2290,6 +2290,7 @@ "diveLog_search_errorLoadingCenters": "Hiba a merulokozpontok betoltesekor", "diveLog_search_errorLoadingDiveTypes": "Hiba a merülés típusok betöltésekor", "diveLog_search_errorLoadingTrips": "Hiba az utazasok betoltesekor", + "diveLog_search_filter_any": "Bármely", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Dekompresszio", "diveLog_search_label_depthRange": "Melyseg tartomany (m)", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 8c43320ad4..a9533ff0d4 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -2290,6 +2290,7 @@ "diveLog_search_errorLoadingCenters": "Errore nel caricamento dei centri immersione", "diveLog_search_errorLoadingDiveTypes": "Errore durante il caricamento dei tipi di immersione", "diveLog_search_errorLoadingTrips": "Errore nel caricamento dei viaggi", + "diveLog_search_filter_any": "Qualsiasi", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Decompressione", "diveLog_search_label_depthRange": "Intervallo profondita (m)", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index c99b5aac9f..f67f478701 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -11903,6 +11903,12 @@ abstract class AppLocalizations { /// **'Error loading trips'** String get diveLog_search_errorLoadingTrips; + /// No description provided for @diveLog_search_filter_any. + /// + /// In en, this message translates to: + /// **'Any'** + String get diveLog_search_filter_any; + /// No description provided for @diveLog_search_gasTrimix. /// /// 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 8128276793..2b513c6308 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -6939,6 +6939,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => 'خطأ في تحميل الرحلات'; + @override + String get diveLog_search_filter_any => 'أي'; + @override String get diveLog_search_gasTrimix => 'ترايمكس (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 11d061c68a..c29d2cb429 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -7082,6 +7082,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => 'Fehler beim Laden der Reisen'; + @override + String get diveLog_search_filter_any => 'Beliebig'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 64faf5a055..fc119f7508 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -6953,6 +6953,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => 'Error loading trips'; + @override + String get diveLog_search_filter_any => 'Any'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 38baf8610a..aeaec6529b 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -7088,6 +7088,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => 'Error al cargar los viajes'; + @override + String get diveLog_search_filter_any => 'Cualquiera'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 1f63002b2a..62fb9257b6 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -7114,6 +7114,9 @@ class AppLocalizationsFr extends AppLocalizations { String get diveLog_search_errorLoadingTrips => 'Erreur de chargement des voyages'; + @override + String get diveLog_search_filter_any => 'Indifférent'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 016f88e64d..83e8b72813 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -6903,6 +6903,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => 'שגיאה בטעינת טיולים'; + @override + String get diveLog_search_filter_any => 'הכול'; + @override String get diveLog_search_gasTrimix => 'טריימיקס (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index b72b76c0e5..cd5d54e15d 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -7063,6 +7063,9 @@ class AppLocalizationsHu extends AppLocalizations { String get diveLog_search_errorLoadingTrips => 'Hiba az utazasok betoltesekor'; + @override + String get diveLog_search_filter_any => 'Bármely'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index f00d63645c..8f83831104 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -7087,6 +7087,9 @@ class AppLocalizationsIt extends AppLocalizations { String get diveLog_search_errorLoadingTrips => 'Errore nel caricamento dei viaggi'; + @override + String get diveLog_search_filter_any => 'Qualsiasi'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 015ee17346..4a33a30d45 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -7029,6 +7029,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => 'Fout bij laden van reizen'; + @override + String get diveLog_search_filter_any => 'Alle'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 4b8577a4c8..bdecbfbe61 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -7087,6 +7087,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => 'Erro ao carregar viagens'; + @override + String get diveLog_search_filter_any => 'Qualquer'; + @override String get diveLog_search_gasTrimix => 'Trimix (<21% O₂)'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 3622de8917..dc02ffbf40 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -6733,6 +6733,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveLog_search_errorLoadingTrips => '加载旅行出错'; + @override + String get diveLog_search_filter_any => '任意'; + @override String get diveLog_search_gasTrimix => '三混气 (<21% O₂)'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index ebc0af6c95..b9ce8f7d93 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -2363,6 +2363,7 @@ "diveLog_search_errorLoadingCenters": "Fout bij laden van duikcentra", "diveLog_search_errorLoadingDiveTypes": "Fout bij laden duiktypes", "diveLog_search_errorLoadingTrips": "Fout bij laden van reizen", + "diveLog_search_filter_any": "Alle", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Decompressie", "diveLog_search_label_depthRange": "Dieptebereik (m)", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 6933401b8e..f6e6c9aed2 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2363,6 +2363,7 @@ "diveLog_search_errorLoadingCenters": "Erro ao carregar centros de mergulho", "diveLog_search_errorLoadingDiveTypes": "Erro ao carregar tipos de mergulho", "diveLog_search_errorLoadingTrips": "Erro ao carregar viagens", + "diveLog_search_filter_any": "Qualquer", "diveLog_search_gasTrimix": "Trimix (<21% O₂)", "diveLog_search_label_deco": "Descompressão", "diveLog_search_label_depthRange": "Faixa de Profundidade (m)", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 3e7b590d0c..57cdec5bdb 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2496,6 +2496,7 @@ "diveLog_search_errorLoadingCenters": "加载潜水中心出错", "diveLog_search_errorLoadingDiveTypes": "加载潜水类型出错", "diveLog_search_errorLoadingTrips": "加载旅行出错", + "diveLog_search_filter_any": "任意", "diveLog_search_gasTrimix": "三混气 (<21% O₂)", "diveLog_search_label_deco": "减压", "diveLog_search_label_depthRange": "深度范围(米)", From 345a621e0df49df3e71ee6e783747118a90e8c51 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 17:53:21 -0400 Subject: [PATCH 121/122] fix(dive-log): resolve the decompression filter in SQL for entity-backed views The deco axis was evaluated in memory by DiveFilterState.apply(), but getAllDives deliberately skips profile hydration for list views, so dive.profile is always empty on that path and deco-stop events never reach the entity at all. _matchesDecoFilter therefore computed neither deco nor no-deco and returned false for both polarities: turning the filter on emptied the dive table view, the dive activity map and the heat map. The existing tests missed it by hand-building Dive objects with a populated profile. Route the axis through SQL everywhere instead: * DiveRepository.getDiveIdsWithDecoSignal resolves the matching ids with the same decoSignalCondition the paginated list and Statistics use, so all three classify dives identically, decoStopStart events included. * decoFilteredDiveIdsProvider (keyed on the wanted polarity, so a Yes/No flip cannot reuse the other polarity's cached ids) wraps it, and filteredDivesProvider intersects. It is only built while the filter is active, keeping the dive_profiles scan off the default list load. * Drop _matchesDecoFilter and document decoOnly as the one axis apply() deliberately does not evaluate. Tests: deco_filter_providers_test.dart drives filteredDivesProvider through the real DB for both polarities, a polarity flip and a deco+date combination with an event-only dive in the fixture; the repository test covers all five signal shapes plus diver scoping and pins the premise that getAllDives leaves profiles unhydrated. --- .../repositories/dive_repository_impl.dart | 48 +++++ .../domain/models/dive_filter_state.dart | 40 ++-- .../providers/dive_providers.dart | 52 ++++- .../statistics/data/dive_filter_sql.dart | 11 +- .../dive_repository_deco_filter_test.dart | 134 +++++++++++-- .../domain/models/dive_filter_state_test.dart | 109 +++++----- .../providers/deco_filter_providers_test.dart | 186 ++++++++++++++++++ .../statistics/data/dive_filter_sql_test.dart | 7 + 8 files changed, 473 insertions(+), 114 deletions(-) create mode 100644 test/features/dive_log/presentation/providers/deco_filter_providers_test.dart diff --git a/lib/features/dive_log/data/repositories/dive_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_repository_impl.dart index 44e2c7cbc5..cca9c9233e 100644 --- a/lib/features/dive_log/data/repositories/dive_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_repository_impl.dart @@ -1981,6 +1981,54 @@ class DiveRepository { } } + /// The ids of every dive whose recorded profile signal classifies it as + /// deco ([wantDeco] true) or no-deco ([wantDeco] false). + /// + /// The in-memory filter path ([DiveFilterState.apply]) cannot answer this: + /// [getAllDives] deliberately skips profile hydration for list views, and + /// deco-stop events never reach the entity at all. So the surfaces built on + /// that path (the table view, the activity and heat maps) resolve the deco + /// axis through this query instead, reusing [decoSignalCondition] so they + /// classify dives exactly as the paginated SQL list does. + /// + /// Only called while the deco filter is active, which keeps the scan over + /// `dive_profiles` off the default dive-list load. + Future> getDiveIdsWithDecoSignal({ + required bool wantDeco, + String? diverId, + }) async { + try { + return await PerfTimer.measure('getDiveIdsWithDecoSignal', () async { + final whereClauses = [ + decoSignalCondition(wantDeco: wantDeco, diveIdRef: 'd.id'), + ]; + final args = >[]; + + if (diverId != null) { + whereClauses.add('d.diver_id = ?'); + args.add(Variable(diverId)); + } + + final rows = await _db + .customSelect( + 'SELECT d.id AS id FROM dives d ' + 'WHERE ${whereClauses.join(' AND ')}', + variables: args, + readsFrom: {_db.dives, _db.diveProfiles, _db.diveProfileEvents}, + ) + .get(); + return rows.map((r) => r.read('id')).toSet(); + }); + } catch (e, stackTrace) { + _log.error( + 'Failed to resolve deco-signal dive ids', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Build SQL WHERE clauses from a [DiveFilterState]. /// /// Builds the SQL ORDER BY clause from the sort state. diff --git a/lib/features/dive_log/domain/models/dive_filter_state.dart b/lib/features/dive_log/domain/models/dive_filter_state.dart index c500940304..eaa1fe4e7f 100644 --- a/lib/features/dive_log/domain/models/dive_filter_state.dart +++ b/lib/features/dive_log/domain/models/dive_filter_state.dart @@ -17,12 +17,16 @@ class DiveFilterState { final double? maxDepth; final bool? favoritesOnly; - /// Decompression status, from the recorded profile signal (deco stop type, - /// deco-stop events, or a positive ceiling with no deco-type data at all — - /// see `scanRecordedDecoSignals` in StatisticsRepository). Null means no - /// filter; true/false restrict to deco/no-deco dives. Dives whose status is + /// Decompression status, derived from the recorded profile signal: a + /// deco-stop profile point, a `decoStopStart` event, or a positive ceiling + /// on a profile carrying no deco-type data at all (mirroring + /// `scanRecordedDecoSignals` in StatisticsRepository). Null means no filter; + /// true/false restrict to deco/no-deco dives. Dives whose status is /// unrecorded (no profile, or a profile needing the computed fallback) /// match neither. + /// + /// This axis is SQL-only. It is applied by `decoSignalCondition` in the + /// query paths and deliberately NOT by [apply]; see the note there. final bool? decoOnly; /// True to restrict the list to dives with no buddy assigned: neither the @@ -238,6 +242,13 @@ class DiveFilterState { /// buildFilteredDiveIdSubquery), so non-paginated views stay consistent with /// the SQL-backed list. It relies on dive.equipment being hydrated with its /// curated attributes (getAllDives does this). + /// + /// [decoOnly] is the one axis this method does NOT apply. getAllDives skips + /// profile hydration for list views and deco-stop events never reach the + /// entity, so there is nothing here to classify a dive from; evaluating it + /// anyway would silently match no dive at all. Callers that honour the deco + /// axis intersect this result with `decoFilteredDiveIdsProvider`, which + /// resolves it through the same SQL condition the paginated list uses. List apply(List dives) { return dives.where((dive) { if (startDate != null && dive.dateTime.isBefore(startDate!)) { @@ -276,9 +287,6 @@ class DiveFilterState { if (favoritesOnly == true && !dive.isFavorite) { return false; } - if (decoOnly != null && !_matchesDecoFilter(dive, decoOnly!)) { - return false; - } if (noBuddyOnly == true) { final hasLegacyBuddy = dive.buddy != null && dive.buddy!.isNotEmpty; if (hasLegacyBuddy || dive.buddies.isNotEmpty) { @@ -388,21 +396,3 @@ class DiveFilterState { }).toList(); } } - -/// Recorded-signal deco classification, mirroring -/// `StatisticsRepository.scanRecordedDecoSignals` (SQL) using the profile -/// points already hydrated on [dive]. Deco-stop *events* are not loaded onto -/// the entity, so unlike the SQL path this only sees the deco-type/ceiling -/// signal; that gap only matters for dives whose computer logs a deco-stop -/// event without also writing profile deco_type/ceiling data, which the -/// paginated (SQL-backed) dive list still classifies correctly. -bool _matchesDecoFilter(Dive dive, bool wantDeco) { - final hasDecoType = dive.profile.any((p) => p.decoType != null); - final hasDecoStop = dive.profile.any((p) => p.decoType == 2); - final hasPositiveCeiling = dive.profile.any( - (p) => p.ceiling != null && p.ceiling! > 0, - ); - final isDeco = hasDecoStop || (!hasDecoType && hasPositiveCeiling); - final isNoDeco = hasDecoType && !hasDecoStop; - return wantDeco ? isDeco : isNoDeco; -} diff --git a/lib/features/dive_log/presentation/providers/dive_providers.dart b/lib/features/dive_log/presentation/providers/dive_providers.dart index b9071fb75e..36b80e883a 100644 --- a/lib/features/dive_log/presentation/providers/dive_providers.dart +++ b/lib/features/dive_log/presentation/providers/dive_providers.dart @@ -45,12 +45,62 @@ final diveSortProvider = StateProvider>( ), ); +/// The ids of every dive matching one polarity of the decompression filter. +/// +/// The deco axis cannot be evaluated in memory: [DiveRepository.getAllDives] +/// deliberately skips profile hydration for list views, and deco-stop events +/// never reach the [domain.Dive] entity at all, so +/// [DiveFilterState.apply] has nothing to classify from. Resolving the ids in +/// SQL instead keeps the entity-backed surfaces (table view, activity and heat +/// maps) in exact agreement with the paginated list, which reads the same +/// `decoSignalCondition`. +/// +/// Keyed on the wanted polarity so flipping Yes/No lands on a fresh instance +/// rather than briefly reusing the other polarity's cached ids. +final decoFilteredDiveIdsProvider = FutureProvider.family, bool>(( + ref, + wantDeco, +) async { + final diverId = ref.watch(currentDiverIdProvider); + final repository = ref.watch(diveRepositoryProvider); + // Profile rows and deco-stop events both feed the classification, so the + // dives tick alone is not enough to keep this fresh. + ref.invalidateSelfWhen(repository.watchAnalysisInputChanges()); + return repository.getDiveIdsWithDecoSignal( + wantDeco: wantDeco, + diverId: diverId, + ); +}); + /// Filtered dives provider - applies current filter to dive list final filteredDivesProvider = Provider>>((ref) { final divesAsync = ref.watch(diveListNotifierProvider); final filter = ref.watch(diveFilterProvider); - return divesAsync.whenData((dives) => filter.apply(dives)); + final decoOnly = filter.decoOnly; + if (decoOnly == null) { + return divesAsync.whenData((dives) => filter.apply(dives)); + } + + final decoAsync = ref.watch(decoFilteredDiveIdsProvider(decoOnly)); + // Built-in AsyncValue.value, not the repo's valueOrNull polyfill: it retains + // the previous ids across a reload, so a profile write does not blank the + // list. Null means first load (or a failure), never a stale answer. + final decoIds = decoAsync.value; + if (decoIds == null) { + if (decoAsync.hasError) { + return AsyncValue.error( + decoAsync.error!, + decoAsync.stackTrace ?? StackTrace.empty, + ); + } + return const AsyncValue.loading(); + } + + return divesAsync.whenData( + (dives) => + filter.apply(dives).where((d) => decoIds.contains(d.id)).toList(), + ); }); /// Sorted and filtered dives provider - applies sort after filter diff --git a/lib/features/statistics/data/dive_filter_sql.dart b/lib/features/statistics/data/dive_filter_sql.dart index bdcfd06ab0..bcaf93b079 100644 --- a/lib/features/statistics/data/dive_filter_sql.dart +++ b/lib/features/statistics/data/dive_filter_sql.dart @@ -223,9 +223,10 @@ import 'package:submersion/features/equipment/domain/constants/equipment_attribu } /// Recorded deco-signal SQL condition (no bind params), shared by -/// [buildFilteredDiveIdSubquery] and -/// `DiveRepositoryImpl._buildFilterWhereClauses` so the two SQL paths -/// (Statistics vs. the paginated dive list) can't drift apart. Mirrors +/// [buildFilteredDiveIdSubquery], `DiveRepository._buildFilterWhereClauses` +/// and `DiveRepository.getDiveIdsWithDecoSignal` so the three SQL paths +/// (Statistics, the paginated dive list, and the id set the entity-backed +/// surfaces intersect with) can't drift apart. Mirrors /// `StatisticsRepository.scanRecordedDecoSignals`: /// /// - A deco-stop profile point (`deco_type = 2`) or a `decoStopStart` event @@ -238,6 +239,10 @@ import 'package:submersion/features/equipment/domain/constants/equipment_attribu /// only classifiable via the computed fallback, which this SQL-only axis /// does not have access to. /// +/// This is the only place the deco axis is evaluated. `DiveFilterState.apply` +/// deliberately skips it, because list-view entities carry neither profile +/// points nor deco-stop events. +/// /// [diveIdRef] must be a reference to the enclosing query's `dives.id` /// resolvable from inside these correlated subqueries (e.g. `d.id` when the /// caller aliases `dives` as `d`, or `dives.id` when it does not). diff --git a/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart b/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart index 78e6a22e1f..d407ba4565 100644 --- a/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart +++ b/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart @@ -1,25 +1,31 @@ +import 'package:drift/drift.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart' as domain; import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart' + as domain; import '../../../../helpers/test_database.dart'; void main() { + late AppDatabase db; late DiveRepository repository; setUp(() async { - await setUpTestDatabase(); + db = await setUpTestDatabase(); repository = DiveRepository(); }); tearDown(() async => tearDownTestDatabase()); - test('decoOnly: true matches a recorded deco stop, decoOnly: false matches ' - 'a recorded no-deco profile', () async { + /// Seeds the five recorded-signal shapes the deco axis has to tell apart. + Future seedDives() async { await repository.createDive( domain.Dive( - id: 'deco', + id: 'stop', dateTime: DateTime(2026, 1, 1), profile: const [ domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 0), @@ -29,7 +35,7 @@ void main() { ); await repository.createDive( domain.Dive( - id: 'noDeco', + id: 'noStop', dateTime: DateTime(2026, 1, 2), profile: const [ domain.DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), @@ -37,45 +43,133 @@ void main() { ), ); await repository.createDive( - domain.Dive(id: 'unrecorded', dateTime: DateTime(2026, 1, 3)), + domain.Dive( + id: 'ceilingOnly', + dateTime: DateTime(2026, 1, 3), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 30, ceiling: 3), + ], + ), ); + // A computer that logs the deco stop as an event without ever writing + // deco_type or ceiling onto the profile samples. + await repository.createDive( + domain.Dive( + id: 'eventOnly', + dateTime: DateTime(2026, 1, 4), + profile: const [domain.DiveProfilePoint(timestamp: 0, depth: 30)], + ), + ); + await db + .into(db.diveProfileEvents) + .insert( + DiveProfileEventsCompanion( + id: const Value('e-1'), + diveId: const Value('eventOnly'), + timestamp: const Value(0), + eventType: const Value('decoStopStart'), + createdAt: Value(DateTime(2026, 1, 4).millisecondsSinceEpoch), + ), + ); + await repository.createDive( + domain.Dive(id: 'unrecorded', dateTime: DateTime(2026, 1, 5)), + ); + } + + test('getDiveSummaries classifies every recorded deco signal', () async { + await seedDives(); final decoResults = await repository.getDiveSummaries( filter: const DiveFilterState(decoOnly: true), ); - expect(decoResults.map((d) => d.id).toSet(), {'deco'}); + expect(decoResults.map((d) => d.id).toSet(), { + 'stop', + 'ceilingOnly', + 'eventOnly', + }); final noDecoResults = await repository.getDiveSummaries( filter: const DiveFilterState(decoOnly: false), ); - expect(noDecoResults.map((d) => d.id).toSet(), {'noDeco'}); + expect(noDecoResults.map((d) => d.id).toSet(), {'noStop'}); + }); + + test('getDiveIdsWithDecoSignal agrees with the paginated SQL path', () async { + await seedDives(); + + expect(await repository.getDiveIdsWithDecoSignal(wantDeco: true), { + 'stop', + 'ceilingOnly', + 'eventOnly', + }); + expect(await repository.getDiveIdsWithDecoSignal(wantDeco: false), { + 'noStop', + }); }); - test('in-memory apply() agrees with the SQL path', () { - final dives = [ + test('getDiveIdsWithDecoSignal honours the diver scope', () async { + final diverRepo = DiverRepository(); + Future makeDiver(String name) async { + final diver = await diverRepo.createDiver( + domain.Diver( + id: '', + name: name, + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ), + ); + return diver.id; + } + + final diverA = await makeDiver('A'); + final diverB = await makeDiver('B'); + + await repository.createDive( domain.Dive( - id: 'deco', + id: 'mine', + diverId: diverA, dateTime: DateTime(2026, 1, 1), profile: const [ domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), ], ), + ); + await repository.createDive( domain.Dive( - id: 'noDeco', + id: 'theirs', + diverId: diverB, dateTime: DateTime(2026, 1, 2), profile: const [ - domain.DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), + domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), ], ), - ]; - - expect( - const DiveFilterState(decoOnly: true).apply(dives).map((d) => d.id), - ['deco'], ); + expect( - const DiveFilterState(decoOnly: false).apply(dives).map((d) => d.id), - ['noDeco'], + await repository.getDiveIdsWithDecoSignal( + wantDeco: true, + diverId: diverA, + ), + {'mine'}, ); }); + + test( + 'getAllDives leaves profiles unhydrated, so apply() cannot classify deco', + () async { + await seedDives(); + + final dives = await repository.getAllDives(); + + // The premise the SQL-backed deco axis exists for: list views carry no + // profile points at all, which is why DiveFilterState.apply deliberately + // ignores decoOnly instead of matching nothing. + expect(dives, isNotEmpty); + expect(dives.every((d) => d.profile.isEmpty), isTrue); + expect( + const DiveFilterState(decoOnly: true).apply(dives).map((d) => d.id), + dives.map((d) => d.id), + ); + }, + ); } diff --git a/test/features/dive_log/domain/models/dive_filter_state_test.dart b/test/features/dive_log/domain/models/dive_filter_state_test.dart index 77d602fc92..5255ecb325 100644 --- a/test/features/dive_log/domain/models/dive_filter_state_test.dart +++ b/test/features/dive_log/domain/models/dive_filter_state_test.dart @@ -711,87 +711,66 @@ void main() { }); group('decoOnly axis', () { - test('decoOnly: true matches a dive with a recorded deco stop', () { - const filter = DiveFilterState(decoOnly: true); + // decoOnly is a SQL-only axis: getAllDives skips profile hydration for + // list views and deco-stop events never reach the entity, so apply() + // has nothing to classify from and deliberately ignores it. Consumers + // intersect with decoFilteredDiveIdsProvider instead. Filtering here + // would silently return nothing on every real (unhydrated) list. + test('apply() ignores decoOnly: true', () { + final dives = [_makeDive(id: 'd1'), _makeDive(id: 'd2')]; + + expect( + const DiveFilterState(decoOnly: true).apply(dives).map((d) => d.id), + ['d1', 'd2'], + ); + }); + + test('apply() ignores decoOnly: false', () { + final dives = [_makeDive(id: 'd1'), _makeDive(id: 'd2')]; + + expect( + const DiveFilterState( + decoOnly: false, + ).apply(dives).map((d) => d.id), + ['d1', 'd2'], + ); + }); + + test('apply() ignores decoOnly even when a profile is hydrated', () { final dives = [ _makeDive( - id: 'd1', + id: 'deco', profile: const [ - DiveProfilePoint(timestamp: 0, depth: 30, decoType: 0), - DiveProfilePoint(timestamp: 60, depth: 30, decoType: 2), + DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), ], ), _makeDive( - id: 'd2', + id: 'noDeco', profile: const [ - DiveProfilePoint(timestamp: 0, depth: 20, decoType: 0), + DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), ], ), ]; - expect(filter.apply(dives).map((d) => d.id), ['d1']); - }); - - test( - 'decoOnly: true matches a positive ceiling with no deco_type data', - () { - const filter = DiveFilterState(decoOnly: true); - final dives = [ - _makeDive( - id: 'd1', - profile: const [ - DiveProfilePoint(timestamp: 0, depth: 30, ceiling: 3), - ], - ), - ]; - - expect(filter.apply(dives).map((d) => d.id), ['d1']); - }, - ); - - test( - 'decoOnly: false matches a profile with deco_type but no stop', - () { - const filter = DiveFilterState(decoOnly: false); - final dives = [ - _makeDive( - id: 'd1', - profile: const [ - DiveProfilePoint(timestamp: 0, depth: 20, decoType: 0), - ], - ), - _makeDive( - id: 'd2', - profile: const [ - DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), - ], - ), - ]; - - expect(filter.apply(dives).map((d) => d.id), ['d1']); - }, - ); - - test('decoOnly excludes dives with no profile data either way', () { - final dives = [_makeDive(id: 'd1')]; - - expect(const DiveFilterState(decoOnly: true).apply(dives), isEmpty); - expect(const DiveFilterState(decoOnly: false).apply(dives), isEmpty); + expect( + const DiveFilterState(decoOnly: true).apply(dives).map((d) => d.id), + ['deco', 'noDeco'], + ); }); - test('decoOnly null applies no deco filtering', () { - const filter = DiveFilterState(); + test('decoOnly still combines with the axes apply() does own', () { final dives = [ - _makeDive(id: 'd1'), - _makeDive( - id: 'd2', - profile: const [ - DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), - ], - ), + _makeDive(id: 'shallow', maxDepth: 12), + _makeDive(id: 'deep', maxDepth: 40), ]; - expect(filter.apply(dives), hasLength(2)); + expect( + const DiveFilterState( + decoOnly: true, + minDepth: 30, + ).apply(dives).map((d) => d.id), + ['deep'], + ); }); }); diff --git a/test/features/dive_log/presentation/providers/deco_filter_providers_test.dart b/test/features/dive_log/presentation/providers/deco_filter_providers_test.dart new file mode 100644 index 0000000000..4ee2ac2dbf --- /dev/null +++ b/test/features/dive_log/presentation/providers/deco_filter_providers_test.dart @@ -0,0 +1,186 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/database/database.dart' + show AppDatabase, DiveProfileEventsCompanion; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../../helpers/test_database.dart'; + +/// The entity-backed surfaces (dive table view, activity map, heat map) all +/// read [filteredDivesProvider]. Their dives come from getAllDives, which does +/// not hydrate profiles, so the decompression axis has to be resolved in SQL. +/// These tests pin that: the deco filter must select the same dives here as it +/// does on the paginated list, including the event-only dive that never +/// reaches the entity at all. +void main() { + late SharedPreferences prefs; + late AppDatabase db; + late DiveRepository diveRepo; + late DiverRepository diverRepo; + late String diverId; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + db = await setUpTestDatabase(); + diveRepo = DiveRepository(); + diverRepo = DiverRepository(); + + final diver = await diverRepo.createDiver( + Diver( + id: '', + name: 'D', + isDefault: true, + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + ), + ); + diverId = diver.id; + await prefs.setString(currentDiverIdKey, diverId); + + await diveRepo.createDive( + Dive( + id: 'deco', + diverId: diverId, + dateTime: DateTime(2026, 1, 1), + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 30, decoType: 0), + DiveProfilePoint(timestamp: 60, depth: 30, decoType: 2), + ], + ), + ); + await diveRepo.createDive( + Dive( + id: 'noDeco', + diverId: diverId, + dateTime: DateTime(2026, 1, 2), + profile: const [DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0)], + ), + ); + // Deco recorded only as an event: invisible to the entity entirely. + await diveRepo.createDive( + Dive( + id: 'eventOnly', + diverId: diverId, + dateTime: DateTime(2026, 1, 3), + profile: const [DiveProfilePoint(timestamp: 0, depth: 30)], + ), + ); + await db + .into(db.diveProfileEvents) + .insert( + DiveProfileEventsCompanion( + id: const Value('e-1'), + diveId: const Value('eventOnly'), + timestamp: const Value(0), + eventType: const Value('decoStopStart'), + createdAt: Value(DateTime(2026, 1, 3).millisecondsSinceEpoch), + ), + ); + await diveRepo.createDive( + Dive(id: 'unrecorded', diverId: diverId, dateTime: DateTime(2026, 1, 4)), + ); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + ProviderContainer makeContainer() { + return ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + } + + /// Reads [filteredDivesProvider] once both the dive list and the deco id set + /// have settled. + Future> filteredIds(ProviderContainer container) async { + for (var i = 0; i < 100; i++) { + final value = container.read(filteredDivesProvider); + if (value.hasValue) return value.value!.map((d) => d.id).toList(); + if (value.hasError) fail('filteredDivesProvider failed: ${value.error}'); + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('filteredDivesProvider never produced a value'); + } + + test('no deco filter leaves every dive in the list', () async { + final container = makeContainer(); + addTearDown(container.dispose); + final sub = container.listen(filteredDivesProvider, (_, _) {}); + addTearDown(sub.close); + + expect(await filteredIds(container), hasLength(4)); + }); + + test('decoOnly: true keeps deco dives, event-only included', () async { + final container = makeContainer(); + addTearDown(container.dispose); + final sub = container.listen(filteredDivesProvider, (_, _) {}); + addTearDown(sub.close); + // Settle the unfiltered list first, mirroring a user turning the filter on + // from the already-rendered list. + await filteredIds(container); + + container.read(diveFilterProvider.notifier).state = const DiveFilterState( + decoOnly: true, + ); + + expect((await filteredIds(container)).toSet(), {'deco', 'eventOnly'}); + }); + + test('decoOnly: false keeps only recorded no-deco dives', () async { + final container = makeContainer(); + addTearDown(container.dispose); + final sub = container.listen(filteredDivesProvider, (_, _) {}); + addTearDown(sub.close); + await filteredIds(container); + + container.read(diveFilterProvider.notifier).state = const DiveFilterState( + decoOnly: false, + ); + + expect((await filteredIds(container)).toSet(), {'noDeco'}); + }); + + test('flipping the deco polarity does not reuse the other set', () async { + final container = makeContainer(); + addTearDown(container.dispose); + final sub = container.listen(filteredDivesProvider, (_, _) {}); + addTearDown(sub.close); + await filteredIds(container); + + container.read(diveFilterProvider.notifier).state = const DiveFilterState( + decoOnly: true, + ); + expect((await filteredIds(container)).toSet(), {'deco', 'eventOnly'}); + + container.read(diveFilterProvider.notifier).state = const DiveFilterState( + decoOnly: false, + ); + expect((await filteredIds(container)).toSet(), {'noDeco'}); + }); + + test('the deco axis combines with the in-memory axes', () async { + final container = makeContainer(); + addTearDown(container.dispose); + final sub = container.listen(filteredDivesProvider, (_, _) {}); + addTearDown(sub.close); + await filteredIds(container); + + container.read(diveFilterProvider.notifier).state = DiveFilterState( + decoOnly: true, + startDate: DateTime(2026, 1, 3), + ); + + expect((await filteredIds(container)).toSet(), {'eventOnly'}); + }); +} diff --git a/test/features/statistics/data/dive_filter_sql_test.dart b/test/features/statistics/data/dive_filter_sql_test.dart index a03b027b86..d41bc53fb0 100644 --- a/test/features/statistics/data/dive_filter_sql_test.dart +++ b/test/features/statistics/data/dive_filter_sql_test.dart @@ -470,6 +470,13 @@ void main() { // multi-axis combinations. That "SQL mirrors apply()" property is what // lets getStatistics/getSacVolumeTrend/etc. push filtering into SQL // instead of loading every dive into Dart. + // + // decoOnly is the one axis deliberately left out of the battery: it is + // SQL-only. getAllDives does not hydrate profiles and deco-stop events + // never reach the entity, so apply() cannot classify a dive and does not + // try. Its own coverage is the decoOnly test above plus + // deco_filter_providers_test.dart, which pins the entity-backed surfaces + // to the SQL answer via decoFilteredDiveIdsProvider. // --- Parents (FK=ON: must precede the dives that reference them) --- await insertSite('s1'); From ca0e01dbf30e3adb802bb1960400c846f210b3d4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 26 Aug 2026 18:53:26 -0400 Subject: [PATCH 122/122] fix(dive-computer): keep every transmitter's pressure on multi-tank dives libdivecomputer fires DC_SAMPLE_PRESSURE once per air-integrated transmitter, so one profile sample can carry a reading for several tanks. The wrapper accumulated a single pressure/tank pair per sample, so the last transmitter overwrote every earlier one. On a CCR dive with an O2 and a diluent transmitter the O2 tank kept only the readings taken while the diluent was out of comms, which on the reported dive was none at all, and the chart drew it as a flat "(est.)" line between start and end pressure. Record each reading against its own tank in a per-sample array and carry it through to tank_pressure_profiles. pressure/tank are unchanged: they feed the single dive_profiles.pressure column, which has no tank index. The download and re-parse paths now share one grouping helper so they cannot drift apart. Verified against the reporter's raw records: the tank that stored 0 of its 2142 readings now stores all of them, matching their Shearwater Cloud export exactly. Fixes #1223 --- .../data/services/dive_parser.dart | 2 + .../data/services/parsed_dive_mapper.dart | 1 + .../data/services/reparse_service.dart | 25 +- .../domain/entities/downloaded_dive.dart | 8 + .../dive_computer_repository_impl.dart | 34 ++- .../domain/services/tank_pressure_series.dart | 53 ++++ .../android/src/main/cpp/libdc_jni.cpp | 12 +- .../libdivecomputer/DiveComputerApi.g.kt | 60 +++-- .../libdivecomputer/SampleDecoder.kt | 33 ++- .../libdivecomputer/SampleDecoderTest.kt | 46 ++++ .../LibDCDarwin/DiveComputerHostApiImpl.swift | 20 ++ .../ios/Classes/DiveComputerApi.g.swift | 57 +++-- .../src/generated/dive_computer_api.g.dart | 58 +++-- .../linux/dive_computer_api.g.cc | 159 ++++++------ .../linux/dive_computer_api.g.h | 19 +- .../linux/dive_converter.c | 29 +++ .../macos/Classes/libdc_download.c | 11 + .../macos/Classes/libdc_wrapper.h | 8 + .../pigeons/dive_computer_api.dart | 10 + .../test/native/CMakeLists.txt | 24 ++ .../native/test_multi_transmitter_pressure.c | 172 +++++++++++++ .../windows/dive_computer_api.g.cc | 68 +++-- .../windows/dive_computer_api.g.h | 13 + .../windows/dive_converter.cc | 15 ++ .../data/services/reparse_service_test.dart | 98 ++++++++ ...puter_multi_transmitter_pressure_test.dart | 234 ++++++++++++++++++ .../services/tank_pressure_series_test.dart | 110 ++++++++ 27 files changed, 1185 insertions(+), 194 deletions(-) create mode 100644 lib/features/dive_log/domain/services/tank_pressure_series.dart create mode 100644 packages/libdivecomputer_plugin/test/native/test_multi_transmitter_pressure.c create mode 100644 test/features/dive_log/data/repositories/dive_computer_multi_transmitter_pressure_test.dart create mode 100644 test/features/dive_log/domain/services/tank_pressure_series_test.dart diff --git a/lib/features/dive_computer/data/services/dive_parser.dart b/lib/features/dive_computer/data/services/dive_parser.dart index 15b0d92537..7151c6ead6 100644 --- a/lib/features/dive_computer/data/services/dive_parser.dart +++ b/lib/features/dive_computer/data/services/dive_parser.dart @@ -30,6 +30,8 @@ class DiveParser { heading: sample.heading, // Preserve tank index for multi-tank pressure tracking tankIndex: sample.tankIndex, + // Every transmitter's reading at this sample (issue #1223) + tankPressures: sample.tankPressures, // Decompression and rebreather data setpoint: sample.setpoint, ppO2: sample.ppo2, 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..e48835ec9f 100644 --- a/lib/features/dive_computer/data/services/parsed_dive_mapper.dart +++ b/lib/features/dive_computer/data/services/parsed_dive_mapper.dart @@ -56,6 +56,7 @@ DownloadedDive parsedDiveToDownloaded(pigeon.ParsedDive parsed) { temperature: s.temperatureCelsius, pressure: s.pressureBar, tankIndex: s.tankIndex, + tankPressures: s.tankPressuresBar, heartRate: s.heartRate, heading: s.heading, setpoint: s.setpoint, diff --git a/lib/features/dive_computer/data/services/reparse_service.dart b/lib/features/dive_computer/data/services/reparse_service.dart index 4b418d4220..7686334a26 100644 --- a/lib/features/dive_computer/data/services/reparse_service.dart +++ b/lib/features/dive_computer/data/services/reparse_service.dart @@ -6,6 +6,7 @@ import 'package:submersion/core/database/database.dart'; import 'package:submersion/features/dive_computer/data/services/libdc_dive_mode.dart'; import 'package:submersion/features/dive_log/domain/services/bottom_time_calculator.dart'; import 'package:submersion/features/dive_computer/data/services/parsed_tank_resolver.dart'; +import 'package:submersion/features/dive_log/domain/services/tank_pressure_series.dart'; /// Service responsible for applying re-parsed dive computer data back to the /// database while respecting the computer-authored vs user-authored field @@ -747,18 +748,18 @@ class ReparseService { }) async { if (tankIdsByIndex.isEmpty) return; - // Group sample pressures by tank index. - final pressuresByTank = >{}; - for (final s in parsed.samples) { - final pressure = s.pressureBar; - if (pressure != null) { - final idx = s.tankIndex ?? 0; - pressuresByTank.putIfAbsent(idx, () => []).add(( - timestamp: s.timeSeconds, - pressure: pressure, - )); - } - } + // Group sample pressures by tank index. A sample can carry a reading per + // air-integrated transmitter (issue #1223), so this walks tankPressuresBar + // rather than the single pressureBar/tankIndex pair. + final pressuresByTank = groupPressuresByTank([ + for (final s in parsed.samples) + ( + timeSeconds: s.timeSeconds, + pressureBar: s.pressureBar, + tankIndex: s.tankIndex, + tankPressuresBar: s.tankPressuresBar, + ), + ]); if (pressuresByTank.isEmpty) return; // Insert the pressure time-series for each known tank. diff --git a/lib/features/dive_computer/domain/entities/downloaded_dive.dart b/lib/features/dive_computer/domain/entities/downloaded_dive.dart index 47e5e58308..8ee761f711 100644 --- a/lib/features/dive_computer/domain/entities/downloaded_dive.dart +++ b/lib/features/dive_computer/domain/entities/downloaded_dive.dart @@ -201,6 +201,13 @@ class ProfileSample { /// Tank index for pressure (0-based) final int? tankIndex; + /// Every tank's pressure in bar at this sample, indexed by tank index, with + /// null where that tank reported nothing. libdivecomputer reports one + /// pressure per air-integrated transmitter, so a single sample can carry + /// several; [pressure]/[tankIndex] hold only the last of them (issue #1223). + /// Null when the source reports at most one pressure per sample. + final List? tankPressures; + /// Heart rate in bpm (if available) final int? heartRate; @@ -265,6 +272,7 @@ class ProfileSample { this.temperature, this.pressure, this.tankIndex, + this.tankPressures, this.heartRate, this.heading, this.setpoint, diff --git a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart index 34ff57adb8..67192bd3a2 100644 --- a/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_computer_repository_impl.dart @@ -26,6 +26,7 @@ import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart' show GeoPoint; import 'package:submersion/features/dive_log/domain/services/bottom_time_calculator.dart'; import 'package:submersion/features/dive_log/domain/services/dive_altitude_enricher.dart'; +import 'package:submersion/features/dive_log/domain/services/tank_pressure_series.dart'; import 'package:submersion/features/equipment/data/services/dive_equipment_defaulter.dart'; import 'package:submersion/features/pre_dive/data/services/checklist_dive_linker.dart'; import 'package:submersion/core/services/database_service.dart'; @@ -1426,19 +1427,18 @@ class DiveComputerRepository { // Insert per-tank pressure time-series data (batch insert, no individual sync) if (tankIdsByIndex.isNotEmpty) { - // Group pressure readings by tank index - final pressuresByTank = - >{}; - for (final point in points) { - if (point.pressure != null) { - final tankIdx = point.tankIndex ?? 0; - pressuresByTank.putIfAbsent(tankIdx, () => []); - pressuresByTank[tankIdx]!.add(( - timestamp: point.timestamp, - pressure: point.pressure!, - )); - } - } + // Group pressure readings by tank index. A sample can carry a reading + // per air-integrated transmitter (issue #1223), so this walks + // tankPressures rather than the single pressure/tankIndex pair. + final pressuresByTank = groupPressuresByTank([ + for (final point in points) + ( + timeSeconds: point.timestamp, + pressureBar: point.pressure, + tankIndex: point.tankIndex, + tankPressuresBar: point.tankPressures, + ), + ]); // Batch insert pressure data for each tank // No individual sync records - parent dive sync covers child data @@ -2075,6 +2075,13 @@ class ProfilePointData { /// Tank index for pressure (0-based), used for multi-tank pressure tracking final int? tankIndex; + /// Every tank's pressure in bar at this sample, indexed by tank index, with + /// null where that tank reported nothing. A dive computer reports one + /// pressure per air-integrated transmitter, so a single sample can carry + /// several; [pressure]/[tankIndex] hold only the last of them (issue #1223). + /// Null for sources that report at most one pressure per sample. + final List? tankPressures; + /// CCR setpoint in bar final double? setpoint; @@ -2133,6 +2140,7 @@ class ProfilePointData { this.heartRate, this.heading, this.tankIndex, + this.tankPressures, this.setpoint, this.ppO2, this.cns, diff --git a/lib/features/dive_log/domain/services/tank_pressure_series.dart b/lib/features/dive_log/domain/services/tank_pressure_series.dart new file mode 100644 index 0000000000..e632152712 --- /dev/null +++ b/lib/features/dive_log/domain/services/tank_pressure_series.dart @@ -0,0 +1,53 @@ +/// One pressure reading in a tank's time series, in seconds from dive start. +typedef TankPressurePoint = ({int timestamp, double pressure}); + +/// The pressure fields of one dive-computer sample. +/// +/// [tankPressuresBar] is the complete record: libdivecomputer reports one +/// pressure per air-integrated transmitter, so a single sample can carry +/// several. [pressureBar]/[tankIndex] hold only the last of them, and are the +/// fallback for sources that never report more than one tank per sample (UDDF +/// and FIT imports, and native builds predating issue #1223). +typedef TankPressureSampleView = ({ + int timeSeconds, + double? pressureBar, + int? tankIndex, + List? tankPressuresBar, +}); + +/// Splits per-sample transmitter readings into one time series per tank index. +/// +/// Samples are read in order, so each series comes out in sample order. Tanks +/// that reported nothing at a sample are simply absent from that timestamp: a +/// transmitter that loses comms leaves a hole rather than a stale or zero +/// reading. +Map> groupPressuresByTank( + Iterable samples, +) { + final byTank = >{}; + + void add(int tankIndex, int timeSeconds, double pressure) { + byTank.putIfAbsent(tankIndex, () => []).add(( + timestamp: timeSeconds, + pressure: pressure, + )); + } + + for (final sample in samples) { + final perTank = sample.tankPressuresBar; + if (perTank != null) { + // The per-tank list supersedes pressureBar, which is one of its entries. + for (var index = 0; index < perTank.length; index++) { + final pressure = perTank[index]; + if (pressure != null) add(index, sample.timeSeconds, pressure); + } + continue; + } + final pressure = sample.pressureBar; + if (pressure != null) { + add(sample.tankIndex ?? 0, sample.timeSeconds, pressure); + } + } + + return byTank; +} diff --git a/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp b/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp index b32e5811c8..11912e6554 100644 --- a/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp +++ b/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp @@ -999,7 +999,11 @@ Java_com_submersion_libdivecomputer_LibdcWrapper_nativeGetDiveSampleCount( } // Must match SAMPLE_FIELD_COUNT in SampleDecoder.kt. -#define LIBDC_SAMPLE_FIELD_COUNT 28 +#define LIBDC_SAMPLE_FIELD_COUNT (28 + LIBDC_MAX_TANKS) + +// Index of the first per-tank pressure slot; must match TANK_PRESSURE_OFFSET in +// SampleDecoder.kt. +#define LIBDC_TANK_PRESSURE_OFFSET 28 extern "C" JNIEXPORT jdoubleArray JNICALL Java_com_submersion_libdivecomputer_LibdcWrapper_nativeGetDiveSample( @@ -1008,7 +1012,8 @@ Java_com_submersion_libdivecomputer_LibdcWrapper_nativeGetDiveSample( if (index < 0 || static_cast(index) >= dive->sample_count) return nullptr; const libdc_sample_t *s = &dive->samples[index]; - // All 28 fields (14 base + 6 O2 cells + gas mix + heading + 6 cell mV). + // 28 scalar fields (14 base + 6 O2 cells + gas mix + heading + 6 cell mV), + // then one slot per tank for the per-transmitter pressures (issue #1223). // Integer sentinels (UINT32_MAX) are cast to double; NAN doubles pass // through and become null on the Kotlin side. Kotlin indexes this array // positionally (see SampleDecoder.kt): append only, never insert. @@ -1042,6 +1047,9 @@ Java_com_submersion_libdivecomputer_LibdcWrapper_nativeGetDiveSample( static_cast(s->o2_sensor_mv[4]), static_cast(s->o2_sensor_mv[5]) }; + for (unsigned int t = 0; t < LIBDC_MAX_TANKS; t++) { + values[LIBDC_TANK_PRESSURE_OFFSET + t] = s->tank_pressure[t]; + } jdoubleArray result = env->NewDoubleArray(LIBDC_SAMPLE_FIELD_COUNT); env->SetDoubleArrayRegion(result, 0, LIBDC_SAMPLE_FIELD_COUNT, values); return result; diff --git a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt index e695ace5f8..4c444fa939 100644 --- a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt +++ b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt @@ -129,6 +129,16 @@ data class ProfileSample ( val temperatureCelsius: Double? = null, val pressureBar: Double? = null, val tankIndex: Long? = null, + /** + * Every tank's pressure in bar at this sample, indexed by tank index, with + * null where that tank reported nothing. libdivecomputer fires one pressure + * reading per air-integrated transmitter, so a single sample can carry + * several; [pressureBar]/[tankIndex] hold only the last of them and lose the + * rest (issue #1223). Null when the sample carries no pressure at all, and + * trimmed of trailing nulls, so an ordinary single-transmitter dive costs one + * short list per sample. + */ + val tankPressuresBar: List? = null, val heartRate: Long? = null, /** * Compass heading in degrees (0-359) from DC_SAMPLE_BEARING; null when the @@ -179,30 +189,31 @@ data class ProfileSample ( val temperatureCelsius = pigeonVar_list[2] as Double? val pressureBar = pigeonVar_list[3] as Double? val tankIndex = pigeonVar_list[4] as Long? - val heartRate = pigeonVar_list[5] as Long? - val heading = pigeonVar_list[6] as Double? - val setpoint = pigeonVar_list[7] as Double? - val ppo2 = pigeonVar_list[8] as Double? - val cns = pigeonVar_list[9] as Double? - val rbt = pigeonVar_list[10] as Long? - val decoType = pigeonVar_list[11] as Long? - val decoTime = pigeonVar_list[12] as Long? - val decoDepth = pigeonVar_list[13] as Double? - val tts = pigeonVar_list[14] as Long? - val o2Sensor1 = pigeonVar_list[15] as Double? - val o2Sensor2 = pigeonVar_list[16] as Double? - val o2Sensor3 = pigeonVar_list[17] as Double? - val o2Sensor4 = pigeonVar_list[18] as Double? - val o2Sensor5 = pigeonVar_list[19] as Double? - val o2Sensor6 = pigeonVar_list[20] as Double? - val o2SensorMv1 = pigeonVar_list[21] as Long? - val o2SensorMv2 = pigeonVar_list[22] as Long? - val o2SensorMv3 = pigeonVar_list[23] as Long? - val o2SensorMv4 = pigeonVar_list[24] as Long? - val o2SensorMv5 = pigeonVar_list[25] as Long? - val o2SensorMv6 = pigeonVar_list[26] as Long? - val gasMixIndex = pigeonVar_list[27] as Long? - return ProfileSample(timeSeconds, depthMeters, temperatureCelsius, pressureBar, tankIndex, heartRate, heading, setpoint, ppo2, cns, rbt, decoType, decoTime, decoDepth, tts, o2Sensor1, o2Sensor2, o2Sensor3, o2Sensor4, o2Sensor5, o2Sensor6, o2SensorMv1, o2SensorMv2, o2SensorMv3, o2SensorMv4, o2SensorMv5, o2SensorMv6, gasMixIndex) + val tankPressuresBar = pigeonVar_list[5] as List? + val heartRate = pigeonVar_list[6] as Long? + val heading = pigeonVar_list[7] as Double? + val setpoint = pigeonVar_list[8] as Double? + val ppo2 = pigeonVar_list[9] as Double? + val cns = pigeonVar_list[10] as Double? + val rbt = pigeonVar_list[11] as Long? + val decoType = pigeonVar_list[12] as Long? + val decoTime = pigeonVar_list[13] as Long? + val decoDepth = pigeonVar_list[14] as Double? + val tts = pigeonVar_list[15] as Long? + val o2Sensor1 = pigeonVar_list[16] as Double? + val o2Sensor2 = pigeonVar_list[17] as Double? + val o2Sensor3 = pigeonVar_list[18] as Double? + val o2Sensor4 = pigeonVar_list[19] as Double? + val o2Sensor5 = pigeonVar_list[20] as Double? + val o2Sensor6 = pigeonVar_list[21] as Double? + val o2SensorMv1 = pigeonVar_list[22] as Long? + val o2SensorMv2 = pigeonVar_list[23] as Long? + val o2SensorMv3 = pigeonVar_list[24] as Long? + val o2SensorMv4 = pigeonVar_list[25] as Long? + val o2SensorMv5 = pigeonVar_list[26] as Long? + val o2SensorMv6 = pigeonVar_list[27] as Long? + val gasMixIndex = pigeonVar_list[28] as Long? + return ProfileSample(timeSeconds, depthMeters, temperatureCelsius, pressureBar, tankIndex, tankPressuresBar, heartRate, heading, setpoint, ppo2, cns, rbt, decoType, decoTime, decoDepth, tts, o2Sensor1, o2Sensor2, o2Sensor3, o2Sensor4, o2Sensor5, o2Sensor6, o2SensorMv1, o2SensorMv2, o2SensorMv3, o2SensorMv4, o2SensorMv5, o2SensorMv6, gasMixIndex) } } fun toList(): List { @@ -212,6 +223,7 @@ data class ProfileSample ( temperatureCelsius, pressureBar, tankIndex, + tankPressuresBar, heartRate, heading, setpoint, diff --git a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SampleDecoder.kt b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SampleDecoder.kt index 586635b0bf..3f91591781 100644 --- a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SampleDecoder.kt +++ b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SampleDecoder.kt @@ -2,19 +2,47 @@ package com.submersion.libdivecomputer internal const val UINT32_SENTINEL: Long = 4294967295L // UINT32_MAX = unavailable +/** Per-sample pressure slots the JNI side packs. Mirrors LIBDC_MAX_TANKS in libdc_wrapper.h. */ +internal const val LIBDC_MAX_TANKS = 16 + /** * Field count marshalled per sample by nativeGetDiveSample. The JNI side packs * these positionally and must be changed together with this file. Append only: * inserting a field silently renumbers every field after it, with no error on * either side. */ -internal const val SAMPLE_FIELD_COUNT = 28 +internal const val SAMPLE_FIELD_COUNT = 28 + LIBDC_MAX_TANKS + +/** Index of the first per-tank pressure slot. Matches LIBDC_TANK_PRESSURE_OFFSET in libdc_jni.cpp. */ +private const val TANK_PRESSURE_OFFSET = 28 private fun sentinelLong(s: DoubleArray, i: Int): Long? = if (s[i].toLong() == UINT32_SENTINEL) null else s[i].toLong() private fun cellMillivolt(s: DoubleArray, i: Int): Long? = - if (s.size < SAMPLE_FIELD_COUNT) null else sentinelLong(s, i) + if (s.size < 28) null else sentinelLong(s, i) + +/** + * Every tank's pressure at this sample, indexed by tank index, NaN decoded to + * null. Trailing nulls are trimmed and an all-null sample decodes to null, so an + * ordinary single-transmitter dive carries a one-element list per sample. + * + * Issue #1223: libdivecomputer reports one pressure per air-integrated + * transmitter, so a sample can carry several and `pressureBar`/`tankIndex` keep + * only the last of them. + */ +private fun tankPressures(s: DoubleArray): List? { + if (s.size < SAMPLE_FIELD_COUNT) return null + val values = ArrayList(LIBDC_MAX_TANKS) + for (t in 0 until LIBDC_MAX_TANKS) { + val v = s[TANK_PRESSURE_OFFSET + t] + values.add(if (v.isNaN()) null else v) + } + while (values.isNotEmpty() && values.last() == null) { + values.removeAt(values.size - 1) + } + return if (values.isEmpty()) null else values +} /** * Decodes one positional sample array into a [ProfileSample]. Shared by the @@ -26,6 +54,7 @@ internal fun decodeProfileSample(s: DoubleArray): ProfileSample = ProfileSample( temperatureCelsius = if (s[2].isNaN()) null else s[2], pressureBar = if (s[3].isNaN()) null else s[3], tankIndex = sentinelLong(s, 4), + tankPressuresBar = tankPressures(s), heartRate = sentinelLong(s, 5), heading = if (s.size < 22 || s[21].toLong() == UINT32_SENTINEL) null else s[21], setpoint = if (s[6].isNaN()) null else s[6], diff --git a/packages/libdivecomputer_plugin/android/src/test/kotlin/com/submersion/libdivecomputer/SampleDecoderTest.kt b/packages/libdivecomputer_plugin/android/src/test/kotlin/com/submersion/libdivecomputer/SampleDecoderTest.kt index 89e25f9aae..c386d46df5 100644 --- a/packages/libdivecomputer_plugin/android/src/test/kotlin/com/submersion/libdivecomputer/SampleDecoderTest.kt +++ b/packages/libdivecomputer_plugin/android/src/test/kotlin/com/submersion/libdivecomputer/SampleDecoderTest.kt @@ -71,4 +71,50 @@ class SampleDecoderTest { a[22] = 0.0 assertEquals(0L, decodeProfileSample(a).o2SensorMv1) } + + /** Issue #1223: a sample can carry one pressure per air-integrated + * transmitter, packed one slot per tank from index 28. */ + @Test + fun `tank pressures decode from index 28 onward`() { + val a = sampleArray() + a[28] = 192.6 // tank 0 (O2) + a[29] = 191.4 // tank 1 (diluent) + assertEquals(listOf(192.6, 191.4), decodeProfileSample(a).tankPressuresBar) + } + + /** A transmitter out of comms leaves a hole, not a zero. */ + @Test + fun `a tank with no reading decodes as null within the list`() { + val a = sampleArray() + a[29] = 191.4 + assertEquals(listOf(null, 191.4), decodeProfileSample(a).tankPressuresBar) + } + + /** Trailing empty slots are trimmed, so a single-transmitter dive carries a + * one-element list rather than a full LIBDC_MAX_TANKS one. */ + @Test + fun `trailing empty tanks are trimmed`() { + val a = sampleArray() + a[28] = 200.0 + assertEquals(listOf(200.0), decodeProfileSample(a).tankPressuresBar) + } + + /** A sample with no pressure at all carries no list. */ + @Test + fun `all-NaN tank pressures decode as null`() { + assertNull(decodeProfileSample(sampleArray()).tankPressuresBar) + } + + /** A stale .so returns the pre-#1223 28-wide array; the Dart layer then + * falls back to pressureBar/tankIndex rather than seeing a bogus list. */ + @Test + fun `short array yields null tank pressures`() { + val a = sampleArray() + a[3] = 190.0 // pressure + a[4] = 0.0 // tank + val s = decodeProfileSample(a.copyOf(28)) + assertNull(s.tankPressuresBar) + assertEquals(190.0, s.pressureBar!!, 1e-9) + assertEquals(0L, s.tankIndex) + } } diff --git a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift index c64cb04e9e..7f1f58cf11 100644 --- a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift +++ b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift @@ -734,6 +734,7 @@ class DiveComputerHostApiImpl: DiveComputerHostApi { temperatureCelsius: s.temperature.isNaN ? nil : s.temperature, pressureBar: s.pressure.isNaN ? nil : s.pressure, tankIndex: s.tank == UInt32.max ? nil : Int64(s.tank), + tankPressuresBar: tankPressures(of: s), heartRate: s.heartbeat == UInt32.max ? nil : Int64(s.heartbeat), heading: s.heading == UInt32.max ? nil : Double(s.heading), setpoint: s.setpoint.isNaN ? nil : s.setpoint, @@ -988,6 +989,25 @@ class DiveComputerHostApiImpl: DiveComputerHostApi { } } +/// Every tank's pressure at one sample, indexed by tank index, NAN unpacked to +/// nil. Trailing nils are trimmed and an all-nil sample returns nil, so the +/// common single-transmitter dive marshals a one-element list per sample rather +/// than a full LIBDC_MAX_TANKS one. See issue #1223: a sample can carry a +/// reading per air-integrated transmitter, and `pressure`/`tank` hold only the +/// last of them. +private func tankPressures(of sample: libdc_sample_t) -> [Double?]? { + let capacity = Int(LIBDC_MAX_TANKS) + var values = withUnsafePointer(to: sample.tank_pressure) { tuplePtr in + tuplePtr.withMemoryRebound(to: Double.self, capacity: capacity) { buffer in + (0..? tankPressuresBar; + int? heartRate; /// Compass heading in degrees (0-359) from DC_SAMPLE_BEARING; null when the @@ -206,6 +216,7 @@ class ProfileSample { temperatureCelsius, pressureBar, tankIndex, + tankPressuresBar, heartRate, heading, setpoint, @@ -240,29 +251,30 @@ class ProfileSample { temperatureCelsius: result[2] as double?, pressureBar: result[3] as double?, tankIndex: result[4] as int?, - heartRate: result[5] as int?, - heading: result[6] as double?, - setpoint: result[7] as double?, - ppo2: result[8] as double?, - cns: result[9] as double?, - rbt: result[10] as int?, - decoType: result[11] as int?, - decoTime: result[12] as int?, - decoDepth: result[13] as double?, - tts: result[14] as int?, - o2Sensor1: result[15] as double?, - o2Sensor2: result[16] as double?, - o2Sensor3: result[17] as double?, - o2Sensor4: result[18] as double?, - o2Sensor5: result[19] as double?, - o2Sensor6: result[20] as double?, - o2SensorMv1: result[21] as int?, - o2SensorMv2: result[22] as int?, - o2SensorMv3: result[23] as int?, - o2SensorMv4: result[24] as int?, - o2SensorMv5: result[25] as int?, - o2SensorMv6: result[26] as int?, - gasMixIndex: result[27] as int?, + tankPressuresBar: (result[5] as List?)?.cast(), + heartRate: result[6] as int?, + heading: result[7] as double?, + setpoint: result[8] as double?, + ppo2: result[9] as double?, + cns: result[10] as double?, + rbt: result[11] as int?, + decoType: result[12] as int?, + decoTime: result[13] as int?, + decoDepth: result[14] as double?, + tts: result[15] as int?, + o2Sensor1: result[16] as double?, + o2Sensor2: result[17] as double?, + o2Sensor3: result[18] as double?, + o2Sensor4: result[19] as double?, + o2Sensor5: result[20] as double?, + o2Sensor6: result[21] as double?, + o2SensorMv1: result[22] as int?, + o2SensorMv2: result[23] as int?, + o2SensorMv3: result[24] as int?, + o2SensorMv4: result[25] as int?, + o2SensorMv5: result[26] as int?, + o2SensorMv6: result[27] as int?, + gasMixIndex: result[28] as int?, ); } } diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc index 94cc2fe333..c1f94a6351 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc @@ -192,6 +192,7 @@ struct _LibdivecomputerPluginProfileSample { double* temperature_celsius; double* pressure_bar; int64_t* tank_index; + FlValue* tank_pressures_bar; int64_t* heart_rate; double* heading; double* setpoint; @@ -224,6 +225,7 @@ static void libdivecomputer_plugin_profile_sample_dispose(GObject* object) { g_clear_pointer(&self->temperature_celsius, g_free); g_clear_pointer(&self->pressure_bar, g_free); g_clear_pointer(&self->tank_index, g_free); + g_clear_pointer(&self->tank_pressures_bar, fl_value_unref); g_clear_pointer(&self->heart_rate, g_free); g_clear_pointer(&self->heading, g_free); g_clear_pointer(&self->setpoint, g_free); @@ -257,7 +259,7 @@ static void libdivecomputer_plugin_profile_sample_class_init(LibdivecomputerPlug G_OBJECT_CLASS(klass)->dispose = libdivecomputer_plugin_profile_sample_dispose; } -LibdivecomputerPluginProfileSample* libdivecomputer_plugin_profile_sample_new(int64_t time_seconds, double depth_meters, double* temperature_celsius, double* pressure_bar, int64_t* tank_index, int64_t* heart_rate, double* heading, double* setpoint, double* ppo2, double* cns, int64_t* rbt, int64_t* deco_type, int64_t* deco_time, double* deco_depth, int64_t* tts, double* o2_sensor1, double* o2_sensor2, double* o2_sensor3, double* o2_sensor4, double* o2_sensor5, double* o2_sensor6, int64_t* o2_sensor_mv1, int64_t* o2_sensor_mv2, int64_t* o2_sensor_mv3, int64_t* o2_sensor_mv4, int64_t* o2_sensor_mv5, int64_t* o2_sensor_mv6, int64_t* gas_mix_index) { +LibdivecomputerPluginProfileSample* libdivecomputer_plugin_profile_sample_new(int64_t time_seconds, double depth_meters, double* temperature_celsius, double* pressure_bar, int64_t* tank_index, FlValue* tank_pressures_bar, int64_t* heart_rate, double* heading, double* setpoint, double* ppo2, double* cns, int64_t* rbt, int64_t* deco_type, int64_t* deco_time, double* deco_depth, int64_t* tts, double* o2_sensor1, double* o2_sensor2, double* o2_sensor3, double* o2_sensor4, double* o2_sensor5, double* o2_sensor6, int64_t* o2_sensor_mv1, int64_t* o2_sensor_mv2, int64_t* o2_sensor_mv3, int64_t* o2_sensor_mv4, int64_t* o2_sensor_mv5, int64_t* o2_sensor_mv6, int64_t* gas_mix_index) { LibdivecomputerPluginProfileSample* self = LIBDIVECOMPUTER_PLUGIN_PROFILE_SAMPLE(g_object_new(libdivecomputer_plugin_profile_sample_get_type(), nullptr)); self->time_seconds = time_seconds; self->depth_meters = depth_meters; @@ -282,6 +284,12 @@ LibdivecomputerPluginProfileSample* libdivecomputer_plugin_profile_sample_new(in else { self->tank_index = nullptr; } + if (tank_pressures_bar != nullptr) { + self->tank_pressures_bar = fl_value_ref(tank_pressures_bar); + } + else { + self->tank_pressures_bar = nullptr; + } if (heart_rate != nullptr) { self->heart_rate = static_cast(malloc(sizeof(int64_t))); *self->heart_rate = *heart_rate; @@ -471,6 +479,11 @@ int64_t* libdivecomputer_plugin_profile_sample_get_tank_index(LibdivecomputerPlu return self->tank_index; } +FlValue* libdivecomputer_plugin_profile_sample_get_tank_pressures_bar(LibdivecomputerPluginProfileSample* self) { + g_return_val_if_fail(LIBDIVECOMPUTER_PLUGIN_IS_PROFILE_SAMPLE(self), nullptr); + return self->tank_pressures_bar; +} + int64_t* libdivecomputer_plugin_profile_sample_get_heart_rate(LibdivecomputerPluginProfileSample* self) { g_return_val_if_fail(LIBDIVECOMPUTER_PLUGIN_IS_PROFILE_SAMPLE(self), nullptr); return self->heart_rate; @@ -593,6 +606,7 @@ static FlValue* libdivecomputer_plugin_profile_sample_to_list(LibdivecomputerPlu fl_value_append_take(values, self->temperature_celsius != nullptr ? fl_value_new_float(*self->temperature_celsius) : fl_value_new_null()); fl_value_append_take(values, self->pressure_bar != nullptr ? fl_value_new_float(*self->pressure_bar) : fl_value_new_null()); fl_value_append_take(values, self->tank_index != nullptr ? fl_value_new_int(*self->tank_index) : fl_value_new_null()); + fl_value_append_take(values, self->tank_pressures_bar != nullptr ? fl_value_ref(self->tank_pressures_bar) : fl_value_new_null()); fl_value_append_take(values, self->heart_rate != nullptr ? fl_value_new_int(*self->heart_rate) : fl_value_new_null()); fl_value_append_take(values, self->heading != nullptr ? fl_value_new_float(*self->heading) : fl_value_new_null()); fl_value_append_take(values, self->setpoint != nullptr ? fl_value_new_float(*self->setpoint) : fl_value_new_null()); @@ -646,167 +660,172 @@ static LibdivecomputerPluginProfileSample* libdivecomputer_plugin_profile_sample tank_index = &tank_index_value; } FlValue* value5 = fl_value_get_list_value(values, 5); + FlValue* tank_pressures_bar = nullptr; + if (fl_value_get_type(value5) != FL_VALUE_TYPE_NULL) { + tank_pressures_bar = value5; + } + FlValue* value6 = fl_value_get_list_value(values, 6); int64_t* heart_rate = nullptr; int64_t heart_rate_value; - if (fl_value_get_type(value5) != FL_VALUE_TYPE_NULL) { - heart_rate_value = fl_value_get_int(value5); + if (fl_value_get_type(value6) != FL_VALUE_TYPE_NULL) { + heart_rate_value = fl_value_get_int(value6); heart_rate = &heart_rate_value; } - FlValue* value6 = fl_value_get_list_value(values, 6); + FlValue* value7 = fl_value_get_list_value(values, 7); double* heading = nullptr; double heading_value; - if (fl_value_get_type(value6) != FL_VALUE_TYPE_NULL) { - heading_value = fl_value_get_float(value6); + if (fl_value_get_type(value7) != FL_VALUE_TYPE_NULL) { + heading_value = fl_value_get_float(value7); heading = &heading_value; } - FlValue* value7 = fl_value_get_list_value(values, 7); + FlValue* value8 = fl_value_get_list_value(values, 8); double* setpoint = nullptr; double setpoint_value; - if (fl_value_get_type(value7) != FL_VALUE_TYPE_NULL) { - setpoint_value = fl_value_get_float(value7); + if (fl_value_get_type(value8) != FL_VALUE_TYPE_NULL) { + setpoint_value = fl_value_get_float(value8); setpoint = &setpoint_value; } - FlValue* value8 = fl_value_get_list_value(values, 8); + FlValue* value9 = fl_value_get_list_value(values, 9); double* ppo2 = nullptr; double ppo2_value; - if (fl_value_get_type(value8) != FL_VALUE_TYPE_NULL) { - ppo2_value = fl_value_get_float(value8); + if (fl_value_get_type(value9) != FL_VALUE_TYPE_NULL) { + ppo2_value = fl_value_get_float(value9); ppo2 = &ppo2_value; } - FlValue* value9 = fl_value_get_list_value(values, 9); + FlValue* value10 = fl_value_get_list_value(values, 10); double* cns = nullptr; double cns_value; - if (fl_value_get_type(value9) != FL_VALUE_TYPE_NULL) { - cns_value = fl_value_get_float(value9); + if (fl_value_get_type(value10) != FL_VALUE_TYPE_NULL) { + cns_value = fl_value_get_float(value10); cns = &cns_value; } - FlValue* value10 = fl_value_get_list_value(values, 10); + FlValue* value11 = fl_value_get_list_value(values, 11); int64_t* rbt = nullptr; int64_t rbt_value; - if (fl_value_get_type(value10) != FL_VALUE_TYPE_NULL) { - rbt_value = fl_value_get_int(value10); + if (fl_value_get_type(value11) != FL_VALUE_TYPE_NULL) { + rbt_value = fl_value_get_int(value11); rbt = &rbt_value; } - FlValue* value11 = fl_value_get_list_value(values, 11); + FlValue* value12 = fl_value_get_list_value(values, 12); int64_t* deco_type = nullptr; int64_t deco_type_value; - if (fl_value_get_type(value11) != FL_VALUE_TYPE_NULL) { - deco_type_value = fl_value_get_int(value11); + if (fl_value_get_type(value12) != FL_VALUE_TYPE_NULL) { + deco_type_value = fl_value_get_int(value12); deco_type = &deco_type_value; } - FlValue* value12 = fl_value_get_list_value(values, 12); + FlValue* value13 = fl_value_get_list_value(values, 13); int64_t* deco_time = nullptr; int64_t deco_time_value; - if (fl_value_get_type(value12) != FL_VALUE_TYPE_NULL) { - deco_time_value = fl_value_get_int(value12); + if (fl_value_get_type(value13) != FL_VALUE_TYPE_NULL) { + deco_time_value = fl_value_get_int(value13); deco_time = &deco_time_value; } - FlValue* value13 = fl_value_get_list_value(values, 13); + FlValue* value14 = fl_value_get_list_value(values, 14); double* deco_depth = nullptr; double deco_depth_value; - if (fl_value_get_type(value13) != FL_VALUE_TYPE_NULL) { - deco_depth_value = fl_value_get_float(value13); + if (fl_value_get_type(value14) != FL_VALUE_TYPE_NULL) { + deco_depth_value = fl_value_get_float(value14); deco_depth = &deco_depth_value; } - FlValue* value14 = fl_value_get_list_value(values, 14); + FlValue* value15 = fl_value_get_list_value(values, 15); int64_t* tts = nullptr; int64_t tts_value; - if (fl_value_get_type(value14) != FL_VALUE_TYPE_NULL) { - tts_value = fl_value_get_int(value14); + if (fl_value_get_type(value15) != FL_VALUE_TYPE_NULL) { + tts_value = fl_value_get_int(value15); tts = &tts_value; } - FlValue* value15 = fl_value_get_list_value(values, 15); + FlValue* value16 = fl_value_get_list_value(values, 16); double* o2_sensor1 = nullptr; double o2_sensor1_value; - if (fl_value_get_type(value15) != FL_VALUE_TYPE_NULL) { - o2_sensor1_value = fl_value_get_float(value15); + if (fl_value_get_type(value16) != FL_VALUE_TYPE_NULL) { + o2_sensor1_value = fl_value_get_float(value16); o2_sensor1 = &o2_sensor1_value; } - FlValue* value16 = fl_value_get_list_value(values, 16); + FlValue* value17 = fl_value_get_list_value(values, 17); double* o2_sensor2 = nullptr; double o2_sensor2_value; - if (fl_value_get_type(value16) != FL_VALUE_TYPE_NULL) { - o2_sensor2_value = fl_value_get_float(value16); + if (fl_value_get_type(value17) != FL_VALUE_TYPE_NULL) { + o2_sensor2_value = fl_value_get_float(value17); o2_sensor2 = &o2_sensor2_value; } - FlValue* value17 = fl_value_get_list_value(values, 17); + FlValue* value18 = fl_value_get_list_value(values, 18); double* o2_sensor3 = nullptr; double o2_sensor3_value; - if (fl_value_get_type(value17) != FL_VALUE_TYPE_NULL) { - o2_sensor3_value = fl_value_get_float(value17); + if (fl_value_get_type(value18) != FL_VALUE_TYPE_NULL) { + o2_sensor3_value = fl_value_get_float(value18); o2_sensor3 = &o2_sensor3_value; } - FlValue* value18 = fl_value_get_list_value(values, 18); + FlValue* value19 = fl_value_get_list_value(values, 19); double* o2_sensor4 = nullptr; double o2_sensor4_value; - if (fl_value_get_type(value18) != FL_VALUE_TYPE_NULL) { - o2_sensor4_value = fl_value_get_float(value18); + if (fl_value_get_type(value19) != FL_VALUE_TYPE_NULL) { + o2_sensor4_value = fl_value_get_float(value19); o2_sensor4 = &o2_sensor4_value; } - FlValue* value19 = fl_value_get_list_value(values, 19); + FlValue* value20 = fl_value_get_list_value(values, 20); double* o2_sensor5 = nullptr; double o2_sensor5_value; - if (fl_value_get_type(value19) != FL_VALUE_TYPE_NULL) { - o2_sensor5_value = fl_value_get_float(value19); + if (fl_value_get_type(value20) != FL_VALUE_TYPE_NULL) { + o2_sensor5_value = fl_value_get_float(value20); o2_sensor5 = &o2_sensor5_value; } - FlValue* value20 = fl_value_get_list_value(values, 20); + FlValue* value21 = fl_value_get_list_value(values, 21); double* o2_sensor6 = nullptr; double o2_sensor6_value; - if (fl_value_get_type(value20) != FL_VALUE_TYPE_NULL) { - o2_sensor6_value = fl_value_get_float(value20); + if (fl_value_get_type(value21) != FL_VALUE_TYPE_NULL) { + o2_sensor6_value = fl_value_get_float(value21); o2_sensor6 = &o2_sensor6_value; } - FlValue* value21 = fl_value_get_list_value(values, 21); + FlValue* value22 = fl_value_get_list_value(values, 22); int64_t* o2_sensor_mv1 = nullptr; int64_t o2_sensor_mv1_value; - if (fl_value_get_type(value21) != FL_VALUE_TYPE_NULL) { - o2_sensor_mv1_value = fl_value_get_int(value21); + if (fl_value_get_type(value22) != FL_VALUE_TYPE_NULL) { + o2_sensor_mv1_value = fl_value_get_int(value22); o2_sensor_mv1 = &o2_sensor_mv1_value; } - FlValue* value22 = fl_value_get_list_value(values, 22); + FlValue* value23 = fl_value_get_list_value(values, 23); int64_t* o2_sensor_mv2 = nullptr; int64_t o2_sensor_mv2_value; - if (fl_value_get_type(value22) != FL_VALUE_TYPE_NULL) { - o2_sensor_mv2_value = fl_value_get_int(value22); + if (fl_value_get_type(value23) != FL_VALUE_TYPE_NULL) { + o2_sensor_mv2_value = fl_value_get_int(value23); o2_sensor_mv2 = &o2_sensor_mv2_value; } - FlValue* value23 = fl_value_get_list_value(values, 23); + FlValue* value24 = fl_value_get_list_value(values, 24); int64_t* o2_sensor_mv3 = nullptr; int64_t o2_sensor_mv3_value; - if (fl_value_get_type(value23) != FL_VALUE_TYPE_NULL) { - o2_sensor_mv3_value = fl_value_get_int(value23); + if (fl_value_get_type(value24) != FL_VALUE_TYPE_NULL) { + o2_sensor_mv3_value = fl_value_get_int(value24); o2_sensor_mv3 = &o2_sensor_mv3_value; } - FlValue* value24 = fl_value_get_list_value(values, 24); + FlValue* value25 = fl_value_get_list_value(values, 25); int64_t* o2_sensor_mv4 = nullptr; int64_t o2_sensor_mv4_value; - if (fl_value_get_type(value24) != FL_VALUE_TYPE_NULL) { - o2_sensor_mv4_value = fl_value_get_int(value24); + if (fl_value_get_type(value25) != FL_VALUE_TYPE_NULL) { + o2_sensor_mv4_value = fl_value_get_int(value25); o2_sensor_mv4 = &o2_sensor_mv4_value; } - FlValue* value25 = fl_value_get_list_value(values, 25); + FlValue* value26 = fl_value_get_list_value(values, 26); int64_t* o2_sensor_mv5 = nullptr; int64_t o2_sensor_mv5_value; - if (fl_value_get_type(value25) != FL_VALUE_TYPE_NULL) { - o2_sensor_mv5_value = fl_value_get_int(value25); + if (fl_value_get_type(value26) != FL_VALUE_TYPE_NULL) { + o2_sensor_mv5_value = fl_value_get_int(value26); o2_sensor_mv5 = &o2_sensor_mv5_value; } - FlValue* value26 = fl_value_get_list_value(values, 26); + FlValue* value27 = fl_value_get_list_value(values, 27); int64_t* o2_sensor_mv6 = nullptr; int64_t o2_sensor_mv6_value; - if (fl_value_get_type(value26) != FL_VALUE_TYPE_NULL) { - o2_sensor_mv6_value = fl_value_get_int(value26); + if (fl_value_get_type(value27) != FL_VALUE_TYPE_NULL) { + o2_sensor_mv6_value = fl_value_get_int(value27); o2_sensor_mv6 = &o2_sensor_mv6_value; } - FlValue* value27 = fl_value_get_list_value(values, 27); + FlValue* value28 = fl_value_get_list_value(values, 28); int64_t* gas_mix_index = nullptr; int64_t gas_mix_index_value; - if (fl_value_get_type(value27) != FL_VALUE_TYPE_NULL) { - gas_mix_index_value = fl_value_get_int(value27); + if (fl_value_get_type(value28) != FL_VALUE_TYPE_NULL) { + gas_mix_index_value = fl_value_get_int(value28); gas_mix_index = &gas_mix_index_value; } - return libdivecomputer_plugin_profile_sample_new(time_seconds, depth_meters, temperature_celsius, pressure_bar, tank_index, heart_rate, heading, setpoint, ppo2, cns, rbt, deco_type, deco_time, deco_depth, tts, o2_sensor1, o2_sensor2, o2_sensor3, o2_sensor4, o2_sensor5, o2_sensor6, o2_sensor_mv1, o2_sensor_mv2, o2_sensor_mv3, o2_sensor_mv4, o2_sensor_mv5, o2_sensor_mv6, gas_mix_index); + return libdivecomputer_plugin_profile_sample_new(time_seconds, depth_meters, temperature_celsius, pressure_bar, tank_index, tank_pressures_bar, heart_rate, heading, setpoint, ppo2, cns, rbt, deco_type, deco_time, deco_depth, tts, o2_sensor1, o2_sensor2, o2_sensor3, o2_sensor4, o2_sensor5, o2_sensor6, o2_sensor_mv1, o2_sensor_mv2, o2_sensor_mv3, o2_sensor_mv4, o2_sensor_mv5, o2_sensor_mv6, gas_mix_index); } struct _LibdivecomputerPluginGasMix { diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h index d15f2564fc..a4619c8137 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h @@ -179,6 +179,7 @@ G_DECLARE_FINAL_TYPE(LibdivecomputerPluginProfileSample, libdivecomputer_plugin_ * temperature_celsius: field in this object. * pressure_bar: field in this object. * tank_index: field in this object. + * tank_pressures_bar: field in this object. * heart_rate: field in this object. * heading: field in this object. * setpoint: field in this object. @@ -207,7 +208,7 @@ G_DECLARE_FINAL_TYPE(LibdivecomputerPluginProfileSample, libdivecomputer_plugin_ * * Returns: a new #LibdivecomputerPluginProfileSample */ -LibdivecomputerPluginProfileSample* libdivecomputer_plugin_profile_sample_new(int64_t time_seconds, double depth_meters, double* temperature_celsius, double* pressure_bar, int64_t* tank_index, int64_t* heart_rate, double* heading, double* setpoint, double* ppo2, double* cns, int64_t* rbt, int64_t* deco_type, int64_t* deco_time, double* deco_depth, int64_t* tts, double* o2_sensor1, double* o2_sensor2, double* o2_sensor3, double* o2_sensor4, double* o2_sensor5, double* o2_sensor6, int64_t* o2_sensor_mv1, int64_t* o2_sensor_mv2, int64_t* o2_sensor_mv3, int64_t* o2_sensor_mv4, int64_t* o2_sensor_mv5, int64_t* o2_sensor_mv6, int64_t* gas_mix_index); +LibdivecomputerPluginProfileSample* libdivecomputer_plugin_profile_sample_new(int64_t time_seconds, double depth_meters, double* temperature_celsius, double* pressure_bar, int64_t* tank_index, FlValue* tank_pressures_bar, int64_t* heart_rate, double* heading, double* setpoint, double* ppo2, double* cns, int64_t* rbt, int64_t* deco_type, int64_t* deco_time, double* deco_depth, int64_t* tts, double* o2_sensor1, double* o2_sensor2, double* o2_sensor3, double* o2_sensor4, double* o2_sensor5, double* o2_sensor6, int64_t* o2_sensor_mv1, int64_t* o2_sensor_mv2, int64_t* o2_sensor_mv3, int64_t* o2_sensor_mv4, int64_t* o2_sensor_mv5, int64_t* o2_sensor_mv6, int64_t* gas_mix_index); /** * libdivecomputer_plugin_profile_sample_get_time_seconds @@ -259,6 +260,22 @@ double* libdivecomputer_plugin_profile_sample_get_pressure_bar(LibdivecomputerPl */ int64_t* libdivecomputer_plugin_profile_sample_get_tank_index(LibdivecomputerPluginProfileSample* object); +/** + * libdivecomputer_plugin_profile_sample_get_tank_pressures_bar + * @object: a #LibdivecomputerPluginProfileSample. + * + * Every tank's pressure in bar at this sample, indexed by tank index, with + * null where that tank reported nothing. libdivecomputer fires one pressure + * reading per air-integrated transmitter, so a single sample can carry + * several; [pressureBar]/[tankIndex] hold only the last of them and lose the + * rest (issue #1223). Null when the sample carries no pressure at all, and + * trimmed of trailing nulls, so an ordinary single-transmitter dive costs one + * short list per sample. + * + * Returns: the field value. + */ +FlValue* libdivecomputer_plugin_profile_sample_get_tank_pressures_bar(LibdivecomputerPluginProfileSample* object); + /** * libdivecomputer_plugin_profile_sample_get_heart_rate * @object: a #LibdivecomputerPluginProfileSample. diff --git a/packages/libdivecomputer_plugin/linux/dive_converter.c b/packages/libdivecomputer_plugin/linux/dive_converter.c index f65757f82f..3819eedbe6 100644 --- a/packages/libdivecomputer_plugin/linux/dive_converter.c +++ b/packages/libdivecomputer_plugin/linux/dive_converter.c @@ -141,9 +141,34 @@ LibdivecomputerPluginParsedDive* convert_parsed_dive( (s->o2_sensor_mv[c] == UINT32_MAX) ? NULL : &mv_vals[c]; } + // Every tank's pressure at this sample (issue #1223): a sample can + // carry one reading per air-integrated transmitter, and `pressure` + // above holds only the last of them. NaN -> null, trailing nulls + // trimmed, all-NaN -> NULL so the field stays absent. The + // constructor takes its own reference, so unref ours afterwards. + int last_tank = -1; + for (int t = LIBDC_MAX_TANKS - 1; t >= 0; t--) { + if (!isnan(s->tank_pressure[t])) { + last_tank = t; + break; + } + } + FlValue* tank_pressures = NULL; + if (last_tank >= 0) { + tank_pressures = fl_value_new_list(); + for (int t = 0; t <= last_tank; t++) { + fl_value_append_take( + tank_pressures, + isnan(s->tank_pressure[t]) + ? fl_value_new_null() + : fl_value_new_float(s->tank_pressure[t])); + } + } + LibdivecomputerPluginProfileSample* sample = libdivecomputer_plugin_profile_sample_new( time_seconds, s->depth, temp_c, pressure, tank_index, + tank_pressures, heart_rate, heading, setpoint, ppo2, cns, rbt, deco_type, deco_time, deco_depth, tts, o2_sensor[0], o2_sensor[1], o2_sensor[2], o2_sensor[3], o2_sensor[4], o2_sensor[5], @@ -151,6 +176,10 @@ LibdivecomputerPluginParsedDive* convert_parsed_dive( o2_sensor_mv[3], o2_sensor_mv[4], o2_sensor_mv[5], gas_mix_index); + if (tank_pressures != NULL) { + fl_value_unref(tank_pressures); + } + fl_value_append_take( samples, fl_value_new_custom_object(132, G_OBJECT(sample))); diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c index 2771ea8863..e0d84db81f 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c @@ -223,6 +223,9 @@ static void sample_callback(dc_sample_type_t type, state->current_sample.temperature = NAN; state->current_sample.pressure = NAN; state->current_sample.tank = UINT32_MAX; + for (unsigned int t = 0; t < LIBDC_MAX_TANKS; t++) { + state->current_sample.tank_pressure[t] = NAN; + } // Carry the active gas forward across samples, not just the switch sample. state->current_sample.gasmix = state->current_gasmix; state->current_sample.heartbeat = UINT32_MAX; @@ -252,6 +255,14 @@ static void sample_callback(dc_sample_type_t type, state->current_sample.temperature = value->temperature; break; case DC_SAMPLE_PRESSURE: + // Issue #1223. libdivecomputer fires this once per transmitter, so a + // single sample can carry a reading for several tanks. Record each one + // against its own tank; keeping only the pair below dropped every tank + // but the last, which drew the others as a flat "(est.)" line. + if (value->pressure.tank < LIBDC_MAX_TANKS) { + state->current_sample.tank_pressure[value->pressure.tank] = + value->pressure.value; + } state->current_sample.pressure = value->pressure.value; state->current_sample.tank = value->pressure.tank; break; diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h index adce03239c..febbd9c166 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h @@ -157,6 +157,14 @@ typedef struct { double temperature; // celsius (NAN if unavailable) double pressure; // bar (NAN if unavailable) unsigned int tank; // tank index (UINT32_MAX if unavailable) + // Per-tank pressure at this sample, indexed by libdivecomputer's tank + // index; NAN where that tank reported nothing. Issue #1223: a dive logged + // with two AI transmitters fires DC_SAMPLE_PRESSURE twice per sample, and + // the single `pressure`/`tank` pair above kept only the last one, so every + // tank but the highest-numbered lost its curve. `pressure`/`tank` still + // carry that last reading, for the single-pressure profile column; this + // array is the complete record. + double tank_pressure[LIBDC_MAX_TANKS]; unsigned int gasmix; // active gas mix index (UINT32_MAX if unavailable) // New fields for full sample capture unsigned int heartbeat; // bpm (UINT32_MAX if unavailable) diff --git a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart index bb3764f1a3..6ee87b7148 100644 --- a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart +++ b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart @@ -57,6 +57,7 @@ class ProfileSample { this.temperatureCelsius, this.pressureBar, this.tankIndex, + this.tankPressuresBar, this.heartRate, this.heading, this.setpoint, @@ -86,6 +87,15 @@ class ProfileSample { final double? temperatureCelsius; final double? pressureBar; final int? tankIndex; + + /// Every tank's pressure in bar at this sample, indexed by tank index, with + /// null where that tank reported nothing. libdivecomputer fires one pressure + /// reading per air-integrated transmitter, so a single sample can carry + /// several; [pressureBar]/[tankIndex] hold only the last of them and lose the + /// rest (issue #1223). Null when the sample carries no pressure at all, and + /// trimmed of trailing nulls, so an ordinary single-transmitter dive costs one + /// short list per sample. + final List? tankPressuresBar; final int? heartRate; /// Compass heading in degrees (0-359) from DC_SAMPLE_BEARING; null when the diff --git a/packages/libdivecomputer_plugin/test/native/CMakeLists.txt b/packages/libdivecomputer_plugin/test/native/CMakeLists.txt index f946f65b52..5860b9f021 100644 --- a/packages/libdivecomputer_plugin/test/native/CMakeLists.txt +++ b/packages/libdivecomputer_plugin/test/native/CMakeLists.txt @@ -185,6 +185,27 @@ if(WIN32) target_link_libraries(test_shearwater_o2_millivolt PRIVATE SetupAPI.lib ws2_32.lib) endif() +# Regression test for issue #1223: a sample carrying two transmitter pressures +# must keep both. Goes through the wrapper, since the loss was in its per-sample +# accumulator rather than in libdivecomputer. +add_executable(test_multi_transmitter_pressure + test_multi_transmitter_pressure.c + ${WRAPPER_DIR}/libdc_wrapper.c + ${WRAPPER_DIR}/libdc_download.c + ${LIBDC_ALL_SOURCES} +) +target_include_directories(test_multi_transmitter_pressure PRIVATE + ${WRAPPER_DIR} + ${LIBDC_DIR}/include + ${LIBDC_DIR}/src + ${PLATFORM_CONFIG_DIR} +) +target_compile_definitions(test_multi_transmitter_pressure PRIVATE HAVE_CONFIG_H) +if(WIN32) + target_compile_definitions(test_multi_transmitter_pressure PRIVATE _CRT_SECURE_NO_WARNINGS) + target_link_libraries(test_multi_transmitter_pressure PRIVATE SetupAPI.lib ws2_32.lib) +endif() + enable_testing() add_test(NAME test_dive_converter COMMAND test_dive_converter) add_test(NAME test_serial_callbacks COMMAND test_serial_callbacks) @@ -196,6 +217,9 @@ add_test(NAME test_shearwater_common_download COMMAND test_shearwater_common_dow add_test(NAME test_parse_raw_dive COMMAND test_parse_raw_dive WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" ) +add_test(NAME test_multi_transmitter_pressure COMMAND test_multi_transmitter_pressure + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" +) add_test(NAME test_shearwater_o2_millivolt COMMAND test_shearwater_o2_millivolt WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" ) diff --git a/packages/libdivecomputer_plugin/test/native/test_multi_transmitter_pressure.c b/packages/libdivecomputer_plugin/test/native/test_multi_transmitter_pressure.c new file mode 100644 index 0000000000..ff9c32bbef --- /dev/null +++ b/packages/libdivecomputer_plugin/test/native/test_multi_transmitter_pressure.c @@ -0,0 +1,172 @@ +/* Issue #1223: a dive logged with two AI transmitters showed one tank as a flat + "(est.)" line, because the wrapper kept a single pressure per sample. + + libdivecomputer fires DC_SAMPLE_PRESSURE once per transmitter, so a sample can + carry several readings before the next DC_SAMPLE_TIME closes it. The wrapper + stored them in one `pressure`/`tank` pair, so the last transmitter of each + sample overwrote every earlier one and the lower-numbered tank kept only the + readings taken while the higher-numbered transmitter was out of comms. + + Fixture: the Petrel 3 CCR dive already used by test_shearwater_o2_millivolt + (issue #810). It carries an O2 and a diluent transmitter, and 407 of its 419 + samples report both. */ + +#include +#include +#include +#include +#include + +#include "libdc_wrapper.h" + +/* Both transmitters report on nearly every sample of the fixture. Counted from + the parser directly (dc_parser_samples_foreach over DC_SAMPLE_PRESSURE). */ +#define EXPECTED_TANK0_READINGS 413 +#define EXPECTED_TANK1_READINGS 410 +#define EXPECTED_SAMPLES 419 + +static unsigned int load_fixture(const char *path, unsigned char **out) { + *out = NULL; + FILE *f = fopen(path, "rb"); + if (!f) return 0; + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + if (len <= 0) { fclose(f); return 0; } + unsigned char *buf = (unsigned char *)malloc((size_t)len); + if (!buf) { fclose(f); return 0; } + size_t read = fread(buf, 1, (size_t)len, f); + fclose(f); + if (read != (size_t)len) { free(buf); return 0; } + *out = buf; + return (unsigned int)read; +} + +static void parse_fixture(libdc_parsed_dive_t *dive) { + unsigned char *data = NULL; + unsigned int size = load_fixture("fixtures/petrel3_ccr_o2_cells.bin", &data); + assert(size == 22400); + assert(data != NULL); + + char err[256] = {0}; + int rc = libdc_parse_raw_dive("Shearwater", "Petrel 3", 10, data, size, dive, + err, sizeof(err)); + if (rc != 0) { + printf("FAIL: libdc_parse_raw_dive returned %d (%s)\n", rc, err); + } + assert(rc == 0); + free(data); +} + +/* Count the samples that carry a reading for each tank. */ +static void count_per_tank(const libdc_parsed_dive_t *dive, + unsigned int *counts, unsigned int ntanks) { + memset(counts, 0, ntanks * sizeof(*counts)); + for (unsigned int i = 0; i < dive->sample_count; i++) { + for (unsigned int t = 0; t < ntanks; t++) { + if (!isnan(dive->samples[i].tank_pressure[t])) counts[t]++; + } + } +} + +/* The heart of #1223: neither transmitter may be dropped when both report on + the same sample. */ +static void test_both_transmitters_survive(void) { + libdc_parsed_dive_t dive; + parse_fixture(&dive); + + assert(dive.tank_count == 2); + assert(dive.sample_count == EXPECTED_SAMPLES); + + unsigned int counts[2]; + count_per_tank(&dive, counts, 2); + printf(" tank 0: %u readings, tank 1: %u readings\n", counts[0], counts[1]); + assert(counts[0] == EXPECTED_TANK0_READINGS); + assert(counts[1] == EXPECTED_TANK1_READINGS); + + /* libdc_parse_raw_dive fills a caller-owned struct: free its arrays, not it. */ + free(dive.samples); + free(dive.events); + printf("PASS: test_both_transmitters_survive\n"); +} + +/* The two series must be distinct: before the fix tank 0's readings were + whatever tank 1 last reported, so the curves were identical wherever both + transmitters were in comms. */ +static void test_tanks_carry_distinct_pressures(void) { + libdc_parsed_dive_t dive; + parse_fixture(&dive); + + unsigned int both = 0; + unsigned int differing = 0; + for (unsigned int i = 0; i < dive.sample_count; i++) { + double p0 = dive.samples[i].tank_pressure[0]; + double p1 = dive.samples[i].tank_pressure[1]; + if (isnan(p0) || isnan(p1)) continue; + both++; + if (fabs(p0 - p1) > 0.01) differing++; + } + printf(" %u samples report both tanks, %u of them differ\n", both, differing); + assert(both >= 400); + assert(differing == both); + + /* libdc_parse_raw_dive fills a caller-owned struct: free its arrays, not it. */ + free(dive.samples); + free(dive.events); + printf("PASS: test_tanks_carry_distinct_pressures\n"); +} + +/* Sanity: the per-sample series must agree with the dive-level tank summary + libdivecomputer reports, so the two cannot be attributed to opposite tanks. */ +static void test_series_match_tank_summary(void) { + libdc_parsed_dive_t dive; + parse_fixture(&dive); + + for (unsigned int t = 0; t < 2; t++) { + double first = NAN, last = NAN; + for (unsigned int i = 0; i < dive.sample_count; i++) { + double p = dive.samples[i].tank_pressure[t]; + if (isnan(p)) continue; + if (isnan(first)) first = p; + last = p; + } + printf(" tank %u: series %.1f -> %.1f bar, summary %.1f -> %.1f bar\n", + t, first, last, dive.tanks[t].beginpressure, + dive.tanks[t].endpressure); + assert(fabs(first - dive.tanks[t].beginpressure) < 1.0); + assert(fabs(last - dive.tanks[t].endpressure) < 1.0); + } + + /* libdc_parse_raw_dive fills a caller-owned struct: free its arrays, not it. */ + free(dive.samples); + free(dive.events); + printf("PASS: test_series_match_tank_summary\n"); +} + +/* A tank that reported nothing at a sample must stay unset, so the Dart layer + can tell "no reading" from "0 bar". */ +static void test_absent_tank_is_nan(void) { + libdc_parsed_dive_t dive; + parse_fixture(&dive); + + for (unsigned int i = 0; i < dive.sample_count; i++) { + for (unsigned int t = 2; t < LIBDC_MAX_TANKS; t++) { + assert(isnan(dive.samples[i].tank_pressure[t])); + } + } + + /* libdc_parse_raw_dive fills a caller-owned struct: free its arrays, not it. */ + free(dive.samples); + free(dive.events); + printf("PASS: test_absent_tank_is_nan\n"); +} + +int main(void) { + printf("Running multi-transmitter pressure tests (issue #1223)...\n"); + test_both_transmitters_survive(); + test_tanks_carry_distinct_pressures(); + test_series_match_tank_summary(); + test_absent_tank_is_nan(); + printf("All multi-transmitter pressure tests passed\n"); + return 0; +} diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc index 54d8d4c7b3..cf5c4099dd 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc @@ -221,6 +221,7 @@ ProfileSample::ProfileSample( const double* temperature_celsius, const double* pressure_bar, const int64_t* tank_index, + const EncodableList* tank_pressures_bar, const int64_t* heart_rate, const double* heading, const double* setpoint, @@ -249,6 +250,7 @@ ProfileSample::ProfileSample( temperature_celsius_(temperature_celsius ? std::optional(*temperature_celsius) : std::nullopt), pressure_bar_(pressure_bar ? std::optional(*pressure_bar) : std::nullopt), tank_index_(tank_index ? std::optional(*tank_index) : std::nullopt), + tank_pressures_bar_(tank_pressures_bar ? std::optional(*tank_pressures_bar) : std::nullopt), heart_rate_(heart_rate ? std::optional(*heart_rate) : std::nullopt), heading_(heading ? std::optional(*heading) : std::nullopt), setpoint_(setpoint ? std::optional(*setpoint) : std::nullopt), @@ -330,6 +332,19 @@ void ProfileSample::set_tank_index(int64_t value_arg) { } +const EncodableList* ProfileSample::tank_pressures_bar() const { + return tank_pressures_bar_ ? &(*tank_pressures_bar_) : nullptr; +} + +void ProfileSample::set_tank_pressures_bar(const EncodableList* value_arg) { + tank_pressures_bar_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void ProfileSample::set_tank_pressures_bar(const EncodableList& value_arg) { + tank_pressures_bar_ = value_arg; +} + + const int64_t* ProfileSample::heart_rate() const { return heart_rate_ ? &(*heart_rate_) : nullptr; } @@ -631,12 +646,13 @@ void ProfileSample::set_gas_mix_index(int64_t value_arg) { EncodableList ProfileSample::ToEncodableList() const { EncodableList list; - list.reserve(28); + list.reserve(29); list.push_back(EncodableValue(time_seconds_)); list.push_back(EncodableValue(depth_meters_)); list.push_back(temperature_celsius_ ? EncodableValue(*temperature_celsius_) : EncodableValue()); list.push_back(pressure_bar_ ? EncodableValue(*pressure_bar_) : EncodableValue()); list.push_back(tank_index_ ? EncodableValue(*tank_index_) : EncodableValue()); + list.push_back(tank_pressures_bar_ ? EncodableValue(*tank_pressures_bar_) : EncodableValue()); list.push_back(heart_rate_ ? EncodableValue(*heart_rate_) : EncodableValue()); list.push_back(heading_ ? EncodableValue(*heading_) : EncodableValue()); list.push_back(setpoint_ ? EncodableValue(*setpoint_) : EncodableValue()); @@ -679,95 +695,99 @@ ProfileSample ProfileSample::FromEncodableList(const EncodableList& list) { if (!encodable_tank_index.IsNull()) { decoded.set_tank_index(std::get(encodable_tank_index)); } - auto& encodable_heart_rate = list[5]; + auto& encodable_tank_pressures_bar = list[5]; + if (!encodable_tank_pressures_bar.IsNull()) { + decoded.set_tank_pressures_bar(std::get(encodable_tank_pressures_bar)); + } + auto& encodable_heart_rate = list[6]; if (!encodable_heart_rate.IsNull()) { decoded.set_heart_rate(std::get(encodable_heart_rate)); } - auto& encodable_heading = list[6]; + auto& encodable_heading = list[7]; if (!encodable_heading.IsNull()) { decoded.set_heading(std::get(encodable_heading)); } - auto& encodable_setpoint = list[7]; + auto& encodable_setpoint = list[8]; if (!encodable_setpoint.IsNull()) { decoded.set_setpoint(std::get(encodable_setpoint)); } - auto& encodable_ppo2 = list[8]; + auto& encodable_ppo2 = list[9]; if (!encodable_ppo2.IsNull()) { decoded.set_ppo2(std::get(encodable_ppo2)); } - auto& encodable_cns = list[9]; + auto& encodable_cns = list[10]; if (!encodable_cns.IsNull()) { decoded.set_cns(std::get(encodable_cns)); } - auto& encodable_rbt = list[10]; + auto& encodable_rbt = list[11]; if (!encodable_rbt.IsNull()) { decoded.set_rbt(std::get(encodable_rbt)); } - auto& encodable_deco_type = list[11]; + auto& encodable_deco_type = list[12]; if (!encodable_deco_type.IsNull()) { decoded.set_deco_type(std::get(encodable_deco_type)); } - auto& encodable_deco_time = list[12]; + auto& encodable_deco_time = list[13]; if (!encodable_deco_time.IsNull()) { decoded.set_deco_time(std::get(encodable_deco_time)); } - auto& encodable_deco_depth = list[13]; + auto& encodable_deco_depth = list[14]; if (!encodable_deco_depth.IsNull()) { decoded.set_deco_depth(std::get(encodable_deco_depth)); } - auto& encodable_tts = list[14]; + auto& encodable_tts = list[15]; if (!encodable_tts.IsNull()) { decoded.set_tts(std::get(encodable_tts)); } - auto& encodable_o2_sensor1 = list[15]; + auto& encodable_o2_sensor1 = list[16]; if (!encodable_o2_sensor1.IsNull()) { decoded.set_o2_sensor1(std::get(encodable_o2_sensor1)); } - auto& encodable_o2_sensor2 = list[16]; + auto& encodable_o2_sensor2 = list[17]; if (!encodable_o2_sensor2.IsNull()) { decoded.set_o2_sensor2(std::get(encodable_o2_sensor2)); } - auto& encodable_o2_sensor3 = list[17]; + auto& encodable_o2_sensor3 = list[18]; if (!encodable_o2_sensor3.IsNull()) { decoded.set_o2_sensor3(std::get(encodable_o2_sensor3)); } - auto& encodable_o2_sensor4 = list[18]; + auto& encodable_o2_sensor4 = list[19]; if (!encodable_o2_sensor4.IsNull()) { decoded.set_o2_sensor4(std::get(encodable_o2_sensor4)); } - auto& encodable_o2_sensor5 = list[19]; + auto& encodable_o2_sensor5 = list[20]; if (!encodable_o2_sensor5.IsNull()) { decoded.set_o2_sensor5(std::get(encodable_o2_sensor5)); } - auto& encodable_o2_sensor6 = list[20]; + auto& encodable_o2_sensor6 = list[21]; if (!encodable_o2_sensor6.IsNull()) { decoded.set_o2_sensor6(std::get(encodable_o2_sensor6)); } - auto& encodable_o2_sensor_mv1 = list[21]; + auto& encodable_o2_sensor_mv1 = list[22]; if (!encodable_o2_sensor_mv1.IsNull()) { decoded.set_o2_sensor_mv1(std::get(encodable_o2_sensor_mv1)); } - auto& encodable_o2_sensor_mv2 = list[22]; + auto& encodable_o2_sensor_mv2 = list[23]; if (!encodable_o2_sensor_mv2.IsNull()) { decoded.set_o2_sensor_mv2(std::get(encodable_o2_sensor_mv2)); } - auto& encodable_o2_sensor_mv3 = list[23]; + auto& encodable_o2_sensor_mv3 = list[24]; if (!encodable_o2_sensor_mv3.IsNull()) { decoded.set_o2_sensor_mv3(std::get(encodable_o2_sensor_mv3)); } - auto& encodable_o2_sensor_mv4 = list[24]; + auto& encodable_o2_sensor_mv4 = list[25]; if (!encodable_o2_sensor_mv4.IsNull()) { decoded.set_o2_sensor_mv4(std::get(encodable_o2_sensor_mv4)); } - auto& encodable_o2_sensor_mv5 = list[25]; + auto& encodable_o2_sensor_mv5 = list[26]; if (!encodable_o2_sensor_mv5.IsNull()) { decoded.set_o2_sensor_mv5(std::get(encodable_o2_sensor_mv5)); } - auto& encodable_o2_sensor_mv6 = list[26]; + auto& encodable_o2_sensor_mv6 = list[27]; if (!encodable_o2_sensor_mv6.IsNull()) { decoded.set_o2_sensor_mv6(std::get(encodable_o2_sensor_mv6)); } - auto& encodable_gas_mix_index = list[27]; + auto& encodable_gas_mix_index = list[28]; if (!encodable_gas_mix_index.IsNull()) { decoded.set_gas_mix_index(std::get(encodable_gas_mix_index)); } diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h index 49d82b0ce3..1d175e8ca3 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h @@ -173,6 +173,7 @@ class ProfileSample { const double* temperature_celsius, const double* pressure_bar, const int64_t* tank_index, + const flutter::EncodableList* tank_pressures_bar, const int64_t* heart_rate, const double* heading, const double* setpoint, @@ -215,6 +216,17 @@ class ProfileSample { void set_tank_index(const int64_t* value_arg); void set_tank_index(int64_t value_arg); + // Every tank's pressure in bar at this sample, indexed by tank index, with + // null where that tank reported nothing. libdivecomputer fires one pressure + // reading per air-integrated transmitter, so a single sample can carry + // several; [pressureBar]/[tankIndex] hold only the last of them and lose the + // rest (issue #1223). Null when the sample carries no pressure at all, and + // trimmed of trailing nulls, so an ordinary single-transmitter dive costs one + // short list per sample. + const flutter::EncodableList* tank_pressures_bar() const; + void set_tank_pressures_bar(const flutter::EncodableList* value_arg); + void set_tank_pressures_bar(const flutter::EncodableList& value_arg); + const int64_t* heart_rate() const; void set_heart_rate(const int64_t* value_arg); void set_heart_rate(int64_t value_arg); @@ -329,6 +341,7 @@ class ProfileSample { std::optional temperature_celsius_; std::optional pressure_bar_; std::optional tank_index_; + std::optional tank_pressures_bar_; std::optional heart_rate_; std::optional heading_; std::optional setpoint_; diff --git a/packages/libdivecomputer_plugin/windows/dive_converter.cc b/packages/libdivecomputer_plugin/windows/dive_converter.cc index 05658f6220..30a4db098d 100644 --- a/packages/libdivecomputer_plugin/windows/dive_converter.cc +++ b/packages/libdivecomputer_plugin/windows/dive_converter.cc @@ -110,6 +110,20 @@ ParsedDive ConvertParsedDive(const libdc_parsed_dive_t& dive) { static_cast(s.o2_sensor_mv[c])); } + // Every tank's pressure at this sample (issue #1223): a sample can + // carry one reading per air-integrated transmitter, and `pressure` + // above holds only the last of them. NaN -> null, trailing nulls + // trimmed, all-NaN -> no list at all. + std::optional tank_pressures; + for (int t = LIBDC_MAX_TANKS - 1; t >= 0; t--) { + if (!tank_pressures && std::isnan(s.tank_pressure[t])) continue; + if (!tank_pressures) tank_pressures.emplace(t + 1); + (*tank_pressures)[t] = + std::isnan(s.tank_pressure[t]) + ? flutter::EncodableValue() + : flutter::EncodableValue(s.tank_pressure[t]); + } + // Nullable ints: UINT32_MAX -> nullptr. std::optional tank_index = (s.tank == UINT32_MAX) @@ -156,6 +170,7 @@ ParsedDive ConvertParsedDive(const libdc_parsed_dive_t& dive) { temp_c ? &*temp_c : nullptr, pressure ? &*pressure : nullptr, tank_index ? &*tank_index : nullptr, + tank_pressures ? &*tank_pressures : nullptr, heart_rate ? &*heart_rate : nullptr, heading ? &*heading : nullptr, setpoint ? &*setpoint : nullptr, diff --git a/test/features/dive_computer/data/services/reparse_service_test.dart b/test/features/dive_computer/data/services/reparse_service_test.dart index bf49b58fff..d915cc45e4 100644 --- a/test/features/dive_computer/data/services/reparse_service_test.dart +++ b/test/features/dive_computer/data/services/reparse_service_test.dart @@ -1663,6 +1663,104 @@ void main() { expect(tank.endPressure, 90.0); }); + test('keeps both transmitters when a sample reports two tank pressures ' + '(issue #1223)', () async { + // A CCR dive with an O2 and a diluent transmitter: libdivecomputer + // reports both on the same sample. The wrapper used to keep only the last + // one, so the O2 tank ended up with no readings at all and the chart drew + // it as a flat "(est.)" line between its start and end pressure. + await insertDive('dive-1'); + await insertComputer('comp-1'); + await insertSource( + id: 'src-1', + diveId: 'dive-1', + computerId: 'comp-1', + isPrimary: true, + ); + + final parsed = makeParsedDive( + tanks: [ + pigeon.TankInfo(index: 0, gasMixIndex: 0, usage: 1), + pigeon.TankInfo(index: 1, gasMixIndex: 1, usage: 2), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0), + pigeon.GasMix(index: 1, o2Percent: 21.0, hePercent: 0.0), + ], + samples: [ + // pressureBar/tankIndex still carry the last reading of each sample; + // the per-tank list is the complete record. + pigeon.ProfileSample( + timeSeconds: 0, + depthMeters: 0.0, + pressureBar: 191.0, + tankIndex: 1, + tankPressuresBar: const [193.0, 191.0], + ), + pigeon.ProfileSample( + timeSeconds: 60, + depthMeters: 18.0, + pressureBar: 150.0, + tankIndex: 1, + tankPressuresBar: const [188.0, 150.0], + ), + // The diluent transmitter drops out: the O2 tank keeps reporting and + // must not inherit the gap. + pigeon.ProfileSample( + timeSeconds: 120, + depthMeters: 5.0, + pressureBar: 185.0, + tankIndex: 0, + tankPressuresBar: const [185.0], + ), + ], + ); + + await service.applyParsedUpdate( + diveId: 'dive-1', + sourceRowId: 'src-1', + parsed: parsed, + descriptorVendor: null, + descriptorProduct: null, + descriptorModel: null, + libdivecomputerVersion: null, + ); + + final tanks = + await (db.select(db.diveTanks) + ..where((t) => t.diveId.equals('dive-1')) + ..orderBy([(t) => OrderingTerm.asc(t.tankOrder)])) + .get(); + expect(tanks, hasLength(2)); + + Future> pressuresFor(String tankId) async { + final rows = + await (db.select(db.tankPressureProfiles) + ..where((t) => t.tankId.equals(tankId)) + ..orderBy([(t) => OrderingTerm.asc(t.timestamp)])) + .get(); + return [for (final r in rows) r.pressure]; + } + + expect(await pressuresFor(tanks[0].id), [ + 193.0, + 188.0, + 185.0, + ], reason: 'the O2 transmitter must keep every reading it reported'); + expect( + await pressuresFor(tanks[1].id), + [191.0, 150.0], + reason: 'the diluent transmitter keeps its own readings, gap included', + ); + + // Start/end pressure is backfilled per tank from its own series, so the + // O2 tank no longer borrows the diluent's numbers. + expect(tanks[0].startPressure, 193.0); + expect(tanks[0].endPressure, 185.0); + expect(tanks[1].startPressure, 191.0); + expect(tanks[1].endPressure, 150.0); + }); + test('derives and inserts gas switches from per-sample gas-mix ' 'transitions on a single-source primary re-parse', () async { // Shearwater-style multi-gas dive: transmitter tank 0 breathes 32%, then diff --git a/test/features/dive_log/data/repositories/dive_computer_multi_transmitter_pressure_test.dart b/test/features/dive_log/data/repositories/dive_computer_multi_transmitter_pressure_test.dart new file mode 100644 index 0000000000..40f945269e --- /dev/null +++ b/test/features/dive_log/data/repositories/dive_computer_multi_transmitter_pressure_test.dart @@ -0,0 +1,234 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_computer_repository_impl.dart'; + +import '../../../../helpers/test_database.dart'; + +/// Issue #1223: a CCR dive logged with an O2 and a diluent transmitter reports +/// both pressures on the same sample. The download path used to keep a single +/// pressure per sample, so the lower-numbered tank kept only the readings taken +/// while the other transmitter was out of comms -- often none at all, which the +/// profile chart drew as a flat "(est.)" line. +void main() { + late DiveComputerRepository repository; + late AppDatabase db; + + setUp(() async { + db = await setUpTestDatabase(); + repository = DiveComputerRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future insertComputer() async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.diveComputers) + .insert( + DiveComputersCompanion( + id: const Value('computer-1'), + name: const Value('Shearwater Petrel 3'), + manufacturer: const Value('Shearwater'), + model: const Value('Petrel 3'), + serialNumber: const Value('SN-1223'), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + return 'computer-1'; + } + + Future> seriesFor( + String tankId, + ) async { + final rows = + await (db.select(db.tankPressureProfiles) + ..where((t) => t.tankId.equals(tankId)) + ..orderBy([(t) => OrderingTerm.asc(t.timestamp)])) + .get(); + return [ + for (final r in rows) (timestamp: r.timestamp, pressure: r.pressure), + ]; + } + + Future> tanksFor(String diveId) => + (db.select(db.diveTanks) + ..where((t) => t.diveId.equals(diveId)) + ..orderBy([(t) => OrderingTerm.asc(t.tankOrder)])) + .get(); + + test('both transmitters keep their own pressure series', () async { + final computerId = await insertComputer(); + + final diveId = await repository.importProfile( + computerId: computerId, + profileStartTime: DateTime(2026, 8, 15, 16, 27), + points: const [ + // pressure/tankIndex hold whichever transmitter the computer reported + // last; tankPressures is the complete record. + ProfilePointData( + timestamp: 0, + depth: 0.0, + pressure: 191.4, + tankIndex: 1, + tankPressures: [192.6, 191.4], + ), + ProfilePointData( + timestamp: 600, + depth: 27.2, + pressure: 150.0, + tankIndex: 1, + tankPressures: [180.0, 150.0], + ), + ProfilePointData( + timestamp: 1200, + depth: 5.0, + pressure: 104.5, + tankIndex: 1, + tankPressures: [162.7, 104.5], + ), + ], + durationSeconds: 1800, + maxDepth: 27.2, + tanks: const [ + TankData(index: 0, o2Percent: 100.0, role: 'oxygenSupply'), + TankData(index: 1, o2Percent: 21.0, role: 'diluent'), + ], + ); + + final tanks = await tanksFor(diveId); + expect(tanks, hasLength(2)); + + expect(await seriesFor(tanks[0].id), [ + (timestamp: 0, pressure: 192.6), + (timestamp: 600, pressure: 180.0), + (timestamp: 1200, pressure: 162.7), + ], reason: 'the O2 transmitter must not be overwritten by the diluent'); + expect(await seriesFor(tanks[1].id), [ + (timestamp: 0, pressure: 191.4), + (timestamp: 600, pressure: 150.0), + (timestamp: 1200, pressure: 104.5), + ]); + }); + + test( + 'a transmitter that drops out leaves a gap, not a borrowed reading', + () async { + final computerId = await insertComputer(); + + final diveId = await repository.importProfile( + computerId: computerId, + profileStartTime: DateTime(2026, 8, 8, 10, 53), + points: const [ + ProfilePointData( + timestamp: 0, + depth: 0.0, + pressure: 204.1, + tankIndex: 1, + tankPressures: [187.4, 204.1], + ), + // "No comms" on the diluent transmitter: only the O2 tank reports. + ProfilePointData( + timestamp: 60, + depth: 10.0, + pressure: 185.0, + tankIndex: 0, + tankPressures: [185.0], + ), + ProfilePointData( + timestamp: 120, + depth: 20.0, + pressure: 190.0, + tankIndex: 1, + tankPressures: [183.0, 190.0], + ), + ], + durationSeconds: 600, + maxDepth: 20.0, + tanks: const [ + TankData(index: 0, o2Percent: 100.0, role: 'oxygenSupply'), + TankData(index: 1, o2Percent: 21.0, role: 'diluent'), + ], + ); + + final tanks = await tanksFor(diveId); + expect( + await seriesFor(tanks[0].id).then((s) => s.map((p) => p.timestamp)), + [0, 60, 120], + ); + expect( + await seriesFor(tanks[1].id).then((s) => s.map((p) => p.timestamp)), + [0, 120], + reason: 'the diluent has no reading at 60s and must not invent one', + ); + }, + ); + + test( + 'start and end pressure are backfilled per tank from its own series', + () async { + final computerId = await insertComputer(); + + final diveId = await repository.importProfile( + computerId: computerId, + profileStartTime: DateTime(2026, 8, 15, 16, 27), + points: const [ + ProfilePointData( + timestamp: 0, + depth: 0.0, + tankPressures: [192.6, 191.4], + ), + ProfilePointData( + timestamp: 1200, + depth: 5.0, + tankPressures: [162.7, 104.5], + ), + ], + durationSeconds: 1800, + maxDepth: 27.2, + // No summary pressures: they come from the transmitter stream. + tanks: const [ + TankData(index: 0, o2Percent: 100.0, role: 'oxygenSupply'), + TankData(index: 1, o2Percent: 21.0, role: 'diluent'), + ], + ); + + final tanks = await tanksFor(diveId); + expect(tanks[0].startPressure, 192.6); + expect(tanks[0].endPressure, 162.7); + expect(tanks[1].startPressure, 191.4); + expect(tanks[1].endPressure, 104.5); + }, + ); + + test( + 'a single-transmitter dive still imports through the fallback path', + () async { + // UDDF/FIT imports and older native builds report one pressure per sample + // with no per-tank list. + final computerId = await insertComputer(); + + final diveId = await repository.importProfile( + computerId: computerId, + profileStartTime: DateTime(2026, 8, 15, 16, 27), + points: const [ + ProfilePointData(timestamp: 0, depth: 0.0, pressure: 200.0), + ProfilePointData(timestamp: 600, depth: 20.0, pressure: 150.0), + ], + durationSeconds: 1200, + maxDepth: 20.0, + tanks: const [TankData(index: 0, o2Percent: 32.0)], + ); + + final tanks = await tanksFor(diveId); + expect(tanks, hasLength(1)); + expect(await seriesFor(tanks[0].id), [ + (timestamp: 0, pressure: 200.0), + (timestamp: 600, pressure: 150.0), + ]); + }, + ); +} diff --git a/test/features/dive_log/domain/services/tank_pressure_series_test.dart b/test/features/dive_log/domain/services/tank_pressure_series_test.dart new file mode 100644 index 0000000000..615e1bf164 --- /dev/null +++ b/test/features/dive_log/domain/services/tank_pressure_series_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/services/tank_pressure_series.dart'; + +TankPressureSampleView _sample( + int timeSeconds, { + double? pressureBar, + int? tankIndex, + List? tankPressuresBar, +}) => ( + timeSeconds: timeSeconds, + pressureBar: pressureBar, + tankIndex: tankIndex, + tankPressuresBar: tankPressuresBar, +); + +void main() { + group('groupPressuresByTank', () { + test('keeps every transmitter reported on the same sample', () { + // Issue #1223: a CCR dive with an O2 and a diluent transmitter reports + // both on nearly every sample. + final series = groupPressuresByTank([ + _sample(0, tankPressuresBar: [193.0, 191.0]), + _sample(10, tankPressuresBar: [192.0, 180.0]), + _sample(20, tankPressuresBar: [191.0, 170.0]), + ]); + + expect(series.keys, unorderedEquals([0, 1])); + expect(series[0]!.map((p) => p.pressure), [193.0, 192.0, 191.0]); + expect(series[1]!.map((p) => p.pressure), [191.0, 180.0, 170.0]); + expect(series[0]!.map((p) => p.timestamp), [0, 10, 20]); + }); + + test('skips the tanks that reported nothing at a sample', () { + // A transmitter that drops out ("no comms") leaves a hole, and the other + // tank must not inherit it. + final series = groupPressuresByTank([ + _sample(0, tankPressuresBar: [193.0, 191.0]), + _sample(10, tankPressuresBar: [192.0, null]), + _sample(20, tankPressuresBar: [null, 170.0]), + ]); + + expect(series[0]!.map((p) => p.timestamp), [0, 10]); + expect(series[1]!.map((p) => p.timestamp), [0, 20]); + }); + + test('falls back to the single reading when no per-tank list is given', () { + // UDDF/FIT imports and older native builds report one pressure per sample. + final series = groupPressuresByTank([ + _sample(0, pressureBar: 200.0, tankIndex: 1), + _sample(10, pressureBar: 190.0, tankIndex: 1), + ]); + + expect(series.keys, [1]); + expect(series[1]!.map((p) => p.pressure), [200.0, 190.0]); + }); + + test('treats a missing tank index on the fallback path as tank 0', () { + final series = groupPressuresByTank([_sample(0, pressureBar: 200.0)]); + + expect(series.keys, [0]); + expect(series[0]!.single.pressure, 200.0); + }); + + test('prefers the per-tank list over the single reading', () { + // pressureBar carries whichever transmitter libdivecomputer reported last, + // so honouring both would duplicate that tank's reading. + final series = groupPressuresByTank([ + _sample( + 0, + pressureBar: 191.0, + tankIndex: 1, + tankPressuresBar: [193.0, 191.0], + ), + ]); + + expect(series[0]!.single.pressure, 193.0); + expect(series[1]!.single.pressure, 191.0); + expect(series[1]!, hasLength(1)); + }); + + test('ignores samples with no pressure at all', () { + final series = groupPressuresByTank([ + _sample(0), + _sample(10, tankPressuresBar: [null, null]), + ]); + + expect(series, isEmpty); + }); + + test('handles an empty sample list', () { + expect(groupPressuresByTank(const []), isEmpty); + }); + + test('preserves sample order within each tank', () { + final series = groupPressuresByTank([ + for (var t = 0; t < 5; t++) + _sample(t * 10, tankPressuresBar: [200.0 - t, 150.0 - t]), + ]); + + expect(series[0]!.map((p) => p.timestamp), [0, 10, 20, 30, 40]); + expect(series[1]!.map((p) => p.pressure), [ + 150.0, + 149.0, + 148.0, + 147.0, + 146.0, + ]); + }); + }); +}