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/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/.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/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 @@ + + 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/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. + +--- 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..30d4ea220a --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-site-location-from-coordinates.md @@ -0,0 +1,3996 @@ +# 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. + +**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', 'Español', '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. 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-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/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..848a2e4f0d --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-site-location-from-coordinates-design.md @@ -0,0 +1,334 @@ +# 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 +`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. 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..0394db256a --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-subsurface-picture-import-design.md @@ -0,0 +1,284 @@ +# 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 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 + +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` (F9), 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. 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/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/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 6b1dae4643..1ec88b3a46 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 @@ -587,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..5965db55c4 --- /dev/null +++ b/ios/fastlane/metadata/review_information/notes.txt @@ -0,0 +1,9 @@ +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. + +"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. + +"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, 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/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/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/core/constants/place_name_language.dart b/lib/core/constants/place_name_language.dart new file mode 100644 index 0000000000..f876c74e01 --- /dev/null +++ b/lib/core/constants/place_name_language.dart @@ -0,0 +1,37 @@ +/// 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: 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) + ? cleaned + : defaultCode; + } +} diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 964f389169..54a66723e7 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'; @@ -1333,6 +1334,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))(); + // v164: 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()(); @@ -1663,6 +1669,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, v166) + TextColumn get placeNameLanguage => + text().withDefault(const Constant('en'))(); // Defaults TextColumn get defaultDiveType => text().withDefault(const Constant('recreational'))(); @@ -1807,6 +1816,18 @@ 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))(); + // 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. 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 @@ -1874,6 +1895,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 +3184,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 = 168; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3444,6 +3466,35 @@ 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, + // v163: diver_settings.default_show_estimated_tank_pressure, the switch + // that suppresses synthesized "(est.)" tank pressure lines on the profile + // chart (issue #731). v162 is skipped rather than missing: main was at + // v161 when that branch was cut, and this branch had already written 162. + // Two branches writing the same scalar auto-merge with no conflict + // marker, so #731 took 163 instead and 162 stays permanently unused. + 163, + // v164: media.manual_elapsed_seconds, the diver's own placement of a + // media item in the dive when its capture time is wrong (issue #1090). + // Renumbered from 162, which #731 landed past while this branch was open. + 164, + // v165 is deliberately absent, not missing: it is claimed by issue #1092 + // (PR #1290, diver_settings.trim_tank_pressure_at_surfacing) on a branch + // that is still open. This ladder is monotonic and unique, not + // contiguous, so do not "fix" the gap by renumbering downwards. + // v166: diver_settings.place_name_language, the synced language used for + // reverse-geocoded country/region/town/body of water (issue #1187). + // Renumbered from 162, which #731 landed past while this branch was open. + 166, + // v167 is likewise absent: it is claimed by issue #1269 (PR #1276) on a + // branch that is still open. + // v168 (issue #638): buddies.is_favorite, so frequently-dived buddies can + // be pinned to the top of the "Add buddy" picker regardless of sort. + // Renumbered from 161, which #1235 landed on main while this branch was + // open. + 168, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4196,6 +4247,21 @@ class AppDatabase extends _$AppDatabase { } } + /// v164: 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. @@ -4479,6 +4545,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 -- @@ -4896,6 +4971,59 @@ 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))', + ); + } + } + + /// 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))', + ); + } + } + + /// v166: place_name_language on diver_settings (issue #1187). Defaults to + /// 'en', the language every pre-v166 row was geocoded in (issue #214). + Future _assertPlaceNameLanguageColumn() async { + 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 @@ -4962,6 +5090,21 @@ class AppDatabase extends _$AppDatabase { } } + /// Idempotent DDL for the v168 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 +8649,34 @@ 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(); + // v163: default_show_estimated_tank_pressure on diver_settings + // (issue #731). + if (from < 163) { + await _assertEstimatedTankPressureDefaultColumn(); + } + if (from < 163) await reportProgress(); + // v164: media.manual_elapsed_seconds (issue #1090). + if (from < 164) { + await _assertMediaManualElapsedColumn(); + } + if (from < 164) await reportProgress(); + // v166: place_name_language on diver_settings (issue #1187). + if (from < 166) { + await _assertPlaceNameLanguageColumn(); + } + if (from < 166) await reportProgress(); + // v168 (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 < 168) { + await _assertBuddyFavoriteColumn(); + } + if (from < 168) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -8696,6 +8867,25 @@ 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(); + + // v163 backstop: re-assert + // diver_settings.default_show_estimated_tank_pressure (issue #731; + // same parallel-branch version-collision self-heal). + await _assertEstimatedTankPressureDefaultColumn(); + + // v164 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(); + + // v168 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(); @@ -8866,6 +9056,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/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/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/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/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/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/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/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..6a4f89999b 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -1,16 +1,30 @@ 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/nominatim_throttle.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) 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; @@ -19,6 +33,11 @@ class LocationResult { final String? country; final String? region; 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, @@ -27,8 +46,19 @@ class LocationResult { this.country, this.region, this.locality, + this.bodyOfWater, + this.geocodeUnavailable = false, }); + /// 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, + networkFailed: geocodeUnavailable, + ); + @override String toString() => 'LocationResult(lat: $latitude, lng: $longitude, country: $country, region: $region)'; @@ -36,32 +66,64 @@ 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. 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=${Uri.encodeQueryComponent(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=${Uri.encodeQueryComponent(languageCode)}', + ); - /// Nominatim forward-geocode URI, English-pinned like the reverse path. + /// 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( '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 @@ -73,6 +135,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; @@ -103,6 +170,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 +256,26 @@ 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, + geocodeUnavailable: place.networkFailed, ); } catch (e, stackTrace) { _log.error( @@ -221,14 +287,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,17 +310,22 @@ 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 ( - 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) { @@ -258,57 +334,110 @@ class LocationService { } // Fallback to OpenStreetMap Nominatim API (works on all platforms) - return await _reverseGeocodeWeb(latitude, longitude); + 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 (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'; + 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(); + } 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(); + } + } - // 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(); + 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); + } - 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); - } - } + /// 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, + ); + 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; + } + } - return (country: null, region: null, locality: null); - } finally { - client.close(); + /// 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 { + await throttle.wait(); + 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) { + throw _NominatimStatusException(response.statusCode); } - } catch (e) { - _log.warning('Web reverse geocoding failed: $e'); - return (country: null, region: null, locality: null); + final body = await response.transform(utf8.decoder).join(); + return jsonDecode(body) as Map; + } finally { + client.close(); } } @@ -324,6 +453,7 @@ class LocationService { final url = buildForwardGeocodeUri(address); + await throttle.wait(); final client = HttpClient(); client.userAgent = 'Submersion Dive Log App'; 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/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/conflict_reference.dart b/lib/core/services/sync/conflict_reference.dart new file mode 100644 index 0000000000..3e96404443 --- /dev/null +++ b/lib/core/services/sync/conflict_reference.dart @@ -0,0 +1,239 @@ +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.exists = true, + 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; + + /// 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 => !exists; +} + +/// 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 { + 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. + /// + /// 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', + '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. 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', + ]; + + /// Date-carrying columns (Unix millis) for entities that are dated, not + /// named. + static const _timestampFields = [ + 'diveDateTime', + 'startDateTime', + '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) => + _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 List.unmodifiable(references); + } + + Future _resolveOne( + String field, + String targetType, + String recordId, + ) async { + 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 _borrowedName(row), + timestamp: _timestampOf(row), + ); + } + + /// 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) { + 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_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index b96af23fb2..c8fcbe35a6 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); @@ -5660,6 +5666,12 @@ 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, + // v166: 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/core/services/sync/sync_service.dart b/lib/core/services/sync/sync_service.dart index 0d9c4c983d..5fd1999a7f 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 { @@ -343,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) { @@ -365,6 +382,16 @@ class SyncService { localModified ?? DateTime.fromMillisecondsSinceEpoch(record.localUpdatedAt), remoteModified: remoteModified ?? DateTime.now(), + localReferences: await _resolveReferences( + resolver, + record.entityType, + localData ?? {}, + ), + remoteReferences: await _resolveReferences( + resolver, + record.entityType, + remoteData, + ), ), ); } catch (e) { @@ -379,6 +406,26 @@ 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( + ConflictReferenceResolver resolver, + String entityType, + Map data, + ) async { + if (data.isEmpty) return const []; + try { + return await resolver.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/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/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/auto_update/presentation/providers/update_providers.dart b/lib/features/auto_update/presentation/providers/update_providers.dart index 3e08adfd0b..ebee0a3570 100644 --- a/lib/features/auto_update/presentation/providers/update_providers.dart +++ b/lib/features/auto_update/presentation/providers/update_providers.dart @@ -1,8 +1,9 @@ import 'dart:io'; -import 'package:submersion/core/providers/provider.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'; @@ -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/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 c523dfb54c..4d8ff761f5 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), ); @@ -750,13 +756,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 @@ -766,7 +794,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(); @@ -785,6 +813,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, ), @@ -813,6 +842,75 @@ 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; + 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, + 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 @@ -829,15 +927,29 @@ 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, + /// 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 .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, + d.id ''', variables: [Variable.withString(buddyId)], ) @@ -966,6 +1078,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_detail_page.dart b/lib/features/buddies/presentation/pages/buddy_detail_page.dart index cc8b2e786b..6f92b6414b 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)), ), ], ), @@ -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/pages/buddy_edit_page.dart b/lib/features/buddies/presentation/pages/buddy_edit_page.dart index de7207407c..50698529f3 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: [ @@ -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 4d90cee316..8ca8c9d05a 100644 --- a/lib/features/buddies/presentation/providers/buddy_providers.dart +++ b/lib/features/buddies/presentation/providers/buddy_providers.dart @@ -61,6 +61,34 @@ 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, @@ -68,26 +96,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); - } - - if (invertForText) { - return sort.direction == SortDirection.ascending - ? -comparison - : comparison; + 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; } - return sort.direction == SortDirection.ascending ? comparison : -comparison; }); return sorted; @@ -191,8 +222,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,20 +239,42 @@ 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)); + 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; @@ -317,6 +378,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..3b6b6070e4 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,40 @@ 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 + // 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.descending, + ) + : 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 +523,7 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { scrollController, buddies, _certsByBuddy, + sort, ); }, loading: () { @@ -497,6 +537,7 @@ class _BuddySelectionSheetState extends ConsumerState<_BuddySelectionSheet> { scrollController, _lastSearchResults!, _certsByBuddy, + sort, ), ), ], @@ -517,14 +558,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 +633,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 +771,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/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..0b44259bfe 100644 --- a/lib/features/certifications/presentation/widgets/certification_list_content.dart +++ b/lib/features/certifications/presentation/widgets/certification_list_content.dart @@ -743,6 +743,10 @@ 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 level = certificationSubtitle(certification); + final levelLabel = level != null ? ', $level' : ''; return Semantics( // Keep the agency: this label stands in for the whole tile, so dropping @@ -750,7 +754,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 +805,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..5883bb5ca0 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,17 +188,31 @@ 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 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. 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' : ''}'; 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 @@ -214,8 +228,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/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/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_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..8a564cb898 100644 --- a/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart +++ b/lib/features/dive_3d/presentation/widgets/terrain_appearance_sheet.dart @@ -14,22 +14,83 @@ 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). + // 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'), 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 +143,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/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/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/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/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 017142e8af..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 @@ -682,7 +683,13 @@ 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. + // 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), @@ -741,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_computer/presentation/providers/discovery_providers.dart b/lib/features/dive_computer/presentation/providers/discovery_providers.dart index 07c89bce98..2b9084de26 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( @@ -259,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); @@ -310,6 +370,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/dive_computer/presentation/providers/download_providers.dart b/lib/features/dive_computer/presentation/providers/download_providers.dart index 9665f45ad2..5ed916c089 100644 --- a/lib/features/dive_computer/presentation/providers/download_providers.dart +++ b/lib/features/dive_computer/presentation/providers/download_providers.dart @@ -17,6 +17,10 @@ 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/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'; /// Provider for the dive computer repository. final diveComputerRepositoryProvider = Provider((ref) { @@ -31,9 +35,25 @@ 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: () => 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_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index 73b58d1bb7..f5dc2ea9c0 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, }); } @@ -227,13 +236,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,6 +1049,7 @@ class UddfEntityImporter { final geocodeResult = await LocationService.instance.reverseGeocode( lat, lon, + languageCode: _placeNameLanguage, ); country ??= geocodeResult.country; region ??= geocodeResult.region; @@ -1108,6 +1123,7 @@ class UddfEntityImporter { final geocodeResult = await LocationService.instance.reverseGeocode( lat, lon, + languageCode: _placeNameLanguage, ); country ??= geocodeResult.country; region ??= geocodeResult.region; @@ -1283,6 +1299,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 +1405,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 +1691,38 @@ 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. + // + // 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, + computerId: computerId, + ); + } catch (e, stackTrace) { + _log.error( + 'Failed to attribute imported dive $diveId to computer ' + '$computerId; the data source keeps the link, so the beforeOpen ' + 'self-heal will adopt it on the next open', + error: e, + stackTrace: stackTrace, + ); + } + } + await DiveEquipmentDefaulter().applyForImportedDive(dive); await ChecklistDiveLinker().applyForImportedDive(dive); await altitudeEnricher.applyForImportedDive(dive); @@ -1910,6 +2034,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..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 @@ -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'; @@ -25,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'; @@ -1392,6 +1394,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), @@ -1422,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 @@ -1598,6 +1602,168 @@ 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; + + // 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), + (t) => OrderingTerm.asc(t.id), + ]); + 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, @@ -1909,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; @@ -1967,6 +2140,7 @@ class ProfilePointData { this.heartRate, this.heading, this.tankIndex, + this.tankPressures, this.setpoint, this.ppO2, this.cns, @@ -2024,6 +2198,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 +2218,9 @@ class TankData { this.startPressure, this.endPressure, this.volumeLiters, + this.workingPressure, + this.material, + this.presetName, this.role, }); } 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..8cf94550b0 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'; @@ -1980,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. @@ -2058,6 +2107,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.noBuddyOnly == true) { clauses.add( "(d.buddy IS NULL OR d.buddy = '') AND " @@ -2074,6 +2128,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, @@ -2681,21 +2749,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 +2801,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 +2811,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 +2821,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 +2851,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(); @@ -2995,22 +3090,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 +3436,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/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/domain/models/dive_filter_state.dart b/lib/features/dive_log/domain/models/dive_filter_state.dart index 359df27d01..a73503ffc7 100644 --- a/lib/features/dive_log/domain/models/dive_filter_state.dart +++ b/lib/features/dive_log/domain/models/dive_filter_state.dart @@ -17,11 +17,29 @@ class DiveFilterState { final double? maxDepth; final bool? favoritesOnly; + /// 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 /// legacy free-text `buddy` field nor a linked buddy is set. 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; @@ -60,8 +78,10 @@ class DiveFilterState { this.minDepth, this.maxDepth, this.favoritesOnly, + this.decoOnly, this.noBuddyOnly, this.tagIds = const [], + this.weekdays = const [], this.equipmentIds = const [], this.buddyNameFilter, this.buddyId, @@ -90,8 +110,10 @@ class DiveFilterState { minDepth != null || maxDepth != null || favoritesOnly == true || + decoOnly != null || noBuddyOnly == true || tagIds.isNotEmpty || + weekdays.isNotEmpty || equipmentIds.isNotEmpty || (buddyNameFilter != null && buddyNameFilter!.isNotEmpty) || buddyId != null || @@ -115,8 +137,10 @@ class DiveFilterState { double? minDepth, double? maxDepth, bool? favoritesOnly, + bool? decoOnly, bool? noBuddyOnly, List? tagIds, + List? weekdays, List? equipmentIds, String? buddyNameFilter, String? buddyId, @@ -142,8 +166,10 @@ class DiveFilterState { bool clearMinDepth = false, bool clearMaxDepth = false, bool clearFavoritesOnly = false, + bool clearDecoOnly = false, bool clearNoBuddyOnly = false, bool clearTagIds = false, + bool clearWeekdays = false, bool clearEquipmentIds = false, bool clearBuddyNameFilter = false, bool clearBuddyId = false, @@ -172,8 +198,10 @@ class DiveFilterState { favoritesOnly: clearFavoritesOnly ? null : (favoritesOnly ?? this.favoritesOnly), + decoOnly: clearDecoOnly ? null : (decoOnly ?? this.decoOnly), noBuddyOnly: clearNoBuddyOnly ? null : (noBuddyOnly ?? this.noBuddyOnly), tagIds: clearTagIds ? const [] : (tagIds ?? this.tagIds), + weekdays: clearWeekdays ? const [] : (weekdays ?? this.weekdays), equipmentIds: clearEquipmentIds ? const [] : (equipmentIds ?? this.equipmentIds), @@ -225,6 +253,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!)) { @@ -275,6 +310,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/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/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/lib/features/dive_log/presentation/pages/dive_detail_page.dart b/lib/features/dive_log/presentation/pages/dive_detail_page.dart index 6b0d6af1c8..f752a5eafd 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'; @@ -83,6 +84,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'; @@ -417,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: () { @@ -459,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: () { @@ -555,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. @@ -608,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({ @@ -632,54 +661,40 @@ 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) { + // 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( - 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; } } @@ -689,6 +704,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 @@ -2273,40 +2344,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) @@ -2479,6 +2543,17 @@ class _DiveDetailPageState extends ConsumerState { ), ); }), + // 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, + onTap: () => context.push('/dives/${dive.id}/edit'), + ), + ], ], ), ), @@ -3665,18 +3740,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) @@ -3688,32 +3769,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( @@ -3722,21 +3797,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, ); } @@ -3830,9 +3903,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, ), @@ -4061,27 +4139,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). 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: [ + _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/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_log/presentation/pages/dive_search_page.dart b/lib/features/dive_log/presentation/pages/dive_search_page.dart index 181570b51c..9000f07ead 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; @@ -55,6 +57,7 @@ class _DiveSearchPageState extends ConsumerState { double? _maxDepth; int? _minDurationMinutes; int? _maxDurationMinutes; + bool? _decoOnly; // Gas & Equipment String? _diveTypeId; @@ -110,6 +113,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; @@ -117,6 +121,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; @@ -138,14 +143,19 @@ 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; } if (_minDepth != null || _maxDepth != null || _minDurationMinutes != null || - _maxDurationMinutes != null) { + _maxDurationMinutes != null || + _decoOnly != null) { _expanded['conditions'] = true; } if (_diveTypeId != null || @@ -375,6 +385,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), + ), + ), ], ); } @@ -541,6 +576,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.diveLog_search_filter_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); + }, + ), + ], + ), ], ); } @@ -807,6 +877,7 @@ class _DiveSearchPageState extends ConsumerState { setState(() { _startDate = null; _endDate = null; + _selectedWeekdays = []; _siteId = null; _tripId = null; _diveCenterId = null; @@ -814,6 +885,7 @@ class _DiveSearchPageState extends ConsumerState { _maxDepth = null; _minDurationMinutes = null; _maxDurationMinutes = null; + _decoOnly = null; _diveTypeId = null; _minO2Percent = null; _maxO2Percent = null; @@ -840,6 +912,7 @@ class _DiveSearchPageState extends ConsumerState { ref.read(_filterProvider.notifier).state = DiveFilterState( startDate: _startDate, endDate: _endDate, + weekdays: _selectedWeekdays, siteId: _siteId, tripId: _tripId, diveCenterId: _diveCenterId, @@ -847,6 +920,7 @@ class _DiveSearchPageState extends ConsumerState { maxDepth: _maxDepth, minBottomTimeMinutes: _minDurationMinutes, maxBottomTimeMinutes: _maxDurationMinutes, + decoOnly: _decoOnly, diveTypeId: _diveTypeId, minO2Percent: _minO2Percent, maxO2Percent: _maxO2Percent, diff --git a/lib/features/dive_log/presentation/providers/dive_providers.dart b/lib/features/dive_log/presentation/providers/dive_providers.dart index f261fdbfce..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 @@ -1054,11 +1104,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/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/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( 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/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/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/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/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..e50d4cfeb9 100644 --- a/lib/features/dive_sites/data/repositories/site_repository_impl.dart +++ b/lib/features/dive_sites/data/repositories/site_repository_impl.dart @@ -1,16 +1,18 @@ 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'; 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/data/mappers/dive_site_row_mapper.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,86 @@ 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), + ), + ); + + /// 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( @@ -817,42 +899,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/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/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/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) 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..e5eb49e491 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'; @@ -211,20 +213,50 @@ class _SiteEditPageState extends ConsumerState { if (!mounted) return; final result = await ref .read(locationServiceProvider) - .reverseGeocode(loc.latitude, loc.longitude); + .reverseGeocode( + loc.latitude, + loc.longitude, + languageCode: ref.read(placeNameLanguageProvider), + ); 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(); @@ -964,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'), @@ -1343,6 +1378,7 @@ class _SiteEditPageState extends ConsumerState { final locationService = ref.read(locationServiceProvider); final result = await locationService.getCurrentLocation( includeGeocoding: true, + languageCode: ref.read(placeNameLanguageProvider), ); if (result == null) { @@ -1376,20 +1412,18 @@ 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) { + // 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), @@ -1426,25 +1460,155 @@ 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( SnackBar( content: Text( - context.l10n.diveSites_edit_snackbar_locationSelectedFromMap, + result.place.networkFailed + ? context.l10n.diveSites_edit_snackbar_lookupFailed + : context.l10n.diveSites_edit_snackbar_locationSelectedFromMap, ), ), ); } } + /// 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/pages/site_list_page.dart b/lib/features/dive_sites/presentation/pages/site_list_page.dart index a4a0b19ee1..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'; @@ -9,6 +11,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 +181,8 @@ class _SiteListPageState extends ConsumerState { value.replaceFirst('view_', ''), ); ref.read(siteListViewModeProvider.notifier).state = mode; + } else if (value == 'fill_location_details') { + unawaited(showSiteLocationBackfillFlow(context, ref)); } }, itemBuilder: (context) { @@ -192,6 +197,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/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/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/features/dive_sites/presentation/widgets/location_picker_map.dart b/lib/features/dive_sites/presentation/widgets/location_picker_map.dart index 7324fdc6e4..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, }); } @@ -64,6 +63,7 @@ class _LocationPickerMapState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( _selectedLocation!.latitude, _selectedLocation!.longitude, + languageCode: ref.read(placeNameLanguageProvider), ); if (mounted) { @@ -99,6 +99,7 @@ class _LocationPickerMapState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( _selectedLocation!.latitude, _selectedLocation!.longitude, + languageCode: ref.read(placeNameLanguageProvider), ); if (mounted) { @@ -106,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/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..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'; @@ -34,6 +36,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 +522,8 @@ class _SiteListContentState extends ConsumerState { _selection.enterExplicit(); } else if (value == 'import') { context.push('/sites/import'); + } else if (value == 'fill_location_details') { + unawaited(showSiteLocationBackfillFlow(context, ref)); } else if (value.startsWith('view_')) { final mode = ListViewMode.fromName( value.replaceFirst('view_', ''), @@ -560,6 +565,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 +798,8 @@ class _SiteListContentState extends ConsumerState { _selection.enterExplicit(); } else if (value == 'import') { context.push('/sites/import'); + } else if (value == 'fill_location_details') { + unawaited(showSiteLocationBackfillFlow(context, ref)); } else if (value.startsWith('view_')) { final mode = ListViewMode.fromName( value.replaceFirst('view_', ''), @@ -809,6 +828,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, + ), + ), ]; }, ), @@ -1104,19 +1133,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 +1338,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 +1347,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 +1381,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 +1456,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/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..ba8a2a3f6e --- /dev/null +++ b/lib/features/dive_sites/presentation/widgets/site_location_backfill_dialog.dart @@ -0,0 +1,137 @@ +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 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): +/// 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); + + // 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) { + 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; + + // No reset here: start() is the only guard against overlapping runs. + 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/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/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..7b3df56e20 --- /dev/null +++ b/lib/features/gps_log/presentation/widgets/gps_track_overview_map.dart @@ -0,0 +1,141 @@ +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. + // + // 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); + + // 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; + if (camera != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) camera.applyTo(controller); + }); + } + } + + return TrackpadZoomMap( + controller: controller, + child: FlutterMap( + mapController: controller, + options: MapOptions( + 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(20, 0), + initialZoom: camera?.zoom ?? 2.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/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index 73f895e508..42a98be129 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -15,6 +15,8 @@ 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_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'; @@ -52,6 +54,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'; @@ -97,10 +101,34 @@ 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. class UniversalAdapter implements ImportSourceAdapter { + static const _log = LoggerService('UniversalAdapter'); + UniversalAdapter({required WidgetRef ref, String displayName = 'File Import'}) : _ref = ref, _displayName = displayName; @@ -204,6 +232,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 @@ -288,6 +327,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( @@ -513,6 +558,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); @@ -526,6 +572,7 @@ class UniversalAdapter implements ImportSourceAdapter { defaultTankPreset: defaultTankPreset, defaultStartPressure: settings.defaultStartPressure, applyDefaultTankToImports: settings.applyDefaultTankToImports, + placeNameLanguage: settings.placeNameLanguage, ); final result = await importer.import( @@ -594,6 +641,34 @@ 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), + selectedIndices: selections[wizard.ImportEntityType.media], + 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, @@ -662,8 +737,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), ); } @@ -857,6 +933,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?; @@ -933,6 +1016,83 @@ 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. + /// + /// [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 + /// 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, + Set? selectedIndices, + 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; + // 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']; + 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/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/dc_adapter_steps.dart b/lib/features/import_wizard/presentation/widgets/dc_adapter_steps.dart index 7ad8a0d767..18ec7f231f 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,70 @@ 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 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; + + if (address == null || !isBluetooth || alreadyAcquired) { + setState(() => _computerResolved = true); + return; + } + + setState(() => _searchingForKnownDevice = true); + 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 +386,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()); } @@ -483,6 +543,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/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/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/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/maps/presentation/widgets/region_download_dialog.dart b/lib/features/maps/presentation/widgets/region_download_dialog.dart index 0fb2a661f9..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,6 +72,7 @@ class _RegionDownloadDialogState extends ConsumerState { final result = await LocationService.instance.reverseGeocode( centerLat, centerLng, + languageCode: ref.read(placeNameLanguageProvider), ); if (mounted && _nameController.text.isEmpty) { 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/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/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/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, () => []) 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/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_library_view.dart b/lib/features/media/presentation/pages/media_library_view.dart index af38dbdc3c..d128a5d8be 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,81 @@ 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. + // + // 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, + 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 +152,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 +168,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/pages/media_viewer_page.dart b/lib/features/media/presentation/pages/media_viewer_page.dart index cc56b26abd..51f0f32127 100644 --- a/lib/features/media/presentation/pages/media_viewer_page.dart +++ b/lib/features/media/presentation/pages/media_viewer_page.dart @@ -20,9 +20,12 @@ 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/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'; @@ -33,6 +36,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 +147,28 @@ 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 choice = await showSetMediaTimeDialog( + context, + profile: profile, + initialElapsedSeconds: setTimeSeedFor( + item, + profileLengthSeconds: MediaDiveWindow.profileLengthSeconds(profile), + ), + 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 +327,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 +513,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 +526,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, ), @@ -717,7 +758,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(); @@ -1411,15 +1458,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 +1560,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 +1595,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,10 +1633,11 @@ class _BottomMetadataOverlay extends StatelessWidget { ); } + /// `+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 minutes = seconds ~/ 60; - final secs = seconds % 60; - return '+$minutes:${secs.toString().padLeft(2, '0')}'; + final formatted = formatElapsedMmSs(seconds); + return seconds < 0 ? formatted : '+$formatted'; } } @@ -1567,11 +1646,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 +1674,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/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_info_panel.dart b/lib/features/media/presentation/widgets/media_info_panel.dart index 15f4cd9f6d..b0627a51e9 100644 --- a/lib/features/media/presentation/widgets/media_info_panel.dart +++ b/lib/features/media/presentation/widgets/media_info_panel.dart @@ -5,16 +5,22 @@ 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/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'; +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 +117,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 +154,18 @@ 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: setTimeSeedFor( + item, + profileLengthSeconds: profileLength, + ), + ), + ], children: [ DiveDetailRow( label: l10n.media_info_filename, @@ -155,6 +192,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 +204,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/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..ded118d22f 100644 --- a/lib/features/media/presentation/widgets/media_library_toolbar.dart +++ b/lib/features/media/presentation/widgets/media_library_toolbar.dart @@ -7,15 +7,36 @@ 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, 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}); + 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 +51,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 +61,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 +79,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 f3536a599f..42ff795c69 100644 --- a/lib/features/media/presentation/widgets/media_selection_bar.dart +++ b/lib/features/media/presentation/widgets/media_selection_bar.dart @@ -4,15 +4,33 @@ 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_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; +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'; -/// Action bar shown above the library while a selection is active: count, -/// Move to dive, Share, Unlink (with confirm), and a clear affordance. +/// The library's contextual bar while a selection is active. +/// +/// The chrome -- count, close, select all, deselect all, and the overflow -- +/// 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/unlink operate on real MediaItems. @@ -70,71 +88,54 @@ class MediaSelectionBar extends ConsumerWidget { if (confirmed != true) return; await ref.read(mediaDeletionCoordinatorProvider).deleteMultipleMedia(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) { - 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), - // Three labelled actions plus the count still overflow a phone - // in portrait, so the row scrolls rather than clipping. - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - 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( - key: const ValueKey('media_library_unlink'), - icon: const Icon(Icons.link_off), - label: Text(context.l10n.media_library_unlinkSelected), - onPressed: selectedItems.isEmpty - ? null - : () => _unlinkSelected(context, ref), - ), - ], - ), - ), - ), - ], + final l10n = context.l10n; + + // [onDelete] is null and Unlink is an ordinary action because this + // surface has no control named Delete: unlinking here destroys the rows, + // but calling it Delete would claim the source files go too, and they do + // not. The shared bar's safety property is kept by other means -- + // [maxInlineActions] is 2, so the two benign actions hold the inline + // slots and the destructive one is reached by open-then-choose, the same + // deliberate gesture the baseline delete entry requires. + return SelectionAppBar( + controller: controller, + selectableIds: selectableIds, + shell: SelectionBarShell.pane, + onDelete: null, + maxInlineActions: 2, + 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), + ), + BulkAction( + id: 'unlink', + icon: Icons.link_off, + label: l10n.media_library_unlinkSelected, + isDestructive: true, + onInvoke: () => _unlinkSelected(context, ref), + ), + ], ); } } 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..ba7e4b3ff4 --- /dev/null +++ b/lib/features/media/presentation/widgets/set_media_time_dialog.dart @@ -0,0 +1,174 @@ +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, + // 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:]')), + ], + 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/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/lib/features/settings/data/repositories/diver_settings_repository.dart b/lib/features/settings/data/repositories/diver_settings_repository.dart index ae273463e5..a698dc6fea 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), @@ -177,6 +179,10 @@ class DiverSettingsRepository { defaultShowGasSwitchMarkers: Value(s.defaultShowGasSwitchMarkers), 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( @@ -258,6 +264,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), @@ -340,6 +347,10 @@ 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( @@ -461,6 +472,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, @@ -541,6 +553,8 @@ class DiverSettingsRepository { defaultShowGasSwitchMarkers: row.defaultShowGasSwitchMarkers, 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 47c4484091..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, @@ -128,6 +135,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/language_settings_page.dart b/lib/features/settings/presentation/pages/language_settings_page.dart index 3dc1cc76df..373be589db 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: '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'), - _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/section_appearance_page.dart b/lib/features/settings/presentation/pages/section_appearance_page.dart index 8de5e03371..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), - 18, + _visibleMetricToggles(settings).length, ), ), trailing: const Icon(Icons.chevron_right), @@ -710,29 +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, - ]; - 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/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index e293eb1593..53e9f4659b 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'; @@ -16,6 +17,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 +557,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), + ), ], ), ), @@ -2998,9 +3021,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) @@ -3039,9 +3060,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 +3071,7 @@ class _AboutSectionContentState extends ConsumerState<_AboutSectionContent> { context.l10n.settings_updates_joinBetaSubtitle, ), onTap: () => launchUrl( - Uri.parse(_betaEnrollUrl), + Uri.parse(enrollUrl), mode: LaunchMode.externalApplication, ), ), @@ -3204,13 +3225,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/lib/features/settings/presentation/providers/debug_log_providers.dart b/lib/features/settings/presentation/providers/debug_log_providers.dart index 709d25aca5..c41f786675 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,94 @@ 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 { +/// Copy the filtered log entries to clipboard, behind the export header. +/// +/// 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, +}) 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/features/settings/presentation/providers/settings_providers.dart b/lib/features/settings/presentation/providers/settings_providers.dart index c1985ee13c..7c9c9afca1 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; @@ -391,6 +396,14 @@ 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; + + /// 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). @@ -483,6 +496,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, @@ -557,6 +571,8 @@ class AppSettings { this.defaultShowGasSwitchMarkers = true, this.defaultShowPhotoMarkers = true, this.defaultShowGasTimeline = false, + this.defaultShowO2CellMv = false, + this.defaultShowEstimatedTankPressure = true, this.defaultShowAscentRateLine = false, // Notification defaults this.notificationsEnabled = true, @@ -642,6 +658,7 @@ class AppSettings { bool? accentSectionHeaders, bool? accentListIcons, String? locale, + String? placeNameLanguage, String? defaultDiveType, double? defaultTankVolume, int? defaultStartPressure, @@ -716,6 +733,8 @@ class AppSettings { bool? defaultShowGasSwitchMarkers, bool? defaultShowPhotoMarkers, bool? defaultShowGasTimeline, + bool? defaultShowO2CellMv, + bool? defaultShowEstimatedTankPressure, bool? defaultShowAscentRateLine, bool? notificationsEnabled, List? serviceReminderDays, @@ -770,6 +789,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, @@ -864,6 +884,10 @@ class AppSettings { defaultShowPhotoMarkers ?? this.defaultShowPhotoMarkers, defaultShowGasTimeline: defaultShowGasTimeline ?? this.defaultShowGasTimeline, + defaultShowO2CellMv: defaultShowO2CellMv ?? this.defaultShowO2CellMv, + defaultShowEstimatedTankPressure: + defaultShowEstimatedTankPressure ?? + this.defaultShowEstimatedTankPressure, defaultShowAscentRateLine: defaultShowAscentRateLine ?? this.defaultShowAscentRateLine, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, @@ -1349,6 +1373,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(); @@ -1788,6 +1819,16 @@ class SettingsNotifier extends StateNotifier { await _saveSettings(); } + Future setDefaultShowO2CellMv(bool value) async { + state = state.copyWith(defaultShowO2CellMv: value); + 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(); @@ -1991,6 +2032,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/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..6a671b3f2f --- /dev/null +++ b/lib/features/settings/presentation/widgets/conflict_data_preview.dart @@ -0,0 +1,375 @@ +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'; +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'; +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}); + +/// 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 -- but +/// only once that sentence has actually been built. +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: 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); + 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)), + entityType: entityType, + data: data, + references: references, + counterpart: counterpart, + ); + + 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. +/// +/// 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) + : null; + + final hidden = { + ..._alwaysHidden, + // 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); + 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, + 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), + value: conflictReferenceValue(l10n, units, reference), + ), + ]; + + if (message != null) { + rows.add(( + label: l10n.settings_conflict_ref_finding, + value: '${message.title}: ${message.detail}', + )); + } + + for (final entry in scalars.entries) { + if (preferred.containsKey(entry.key)) continue; // already led the preview + rows.add(scalarRow(entry)); + } + return rows; +} + +/// 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, + String key, + Object value, +) { + 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), + l10n: l10n, + ); + } + return value.toString(); +} + +/// 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; + 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. +/// +/// 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 either side of the epoch + final named = + key.endsWith('At') || key.endsWith('Time') || key.endsWith('Date'); + return named && value.abs() >= millisFloor; +} + +bool _usable(Map data, Set hidden, String key) => + !hidden.contains(key) && data[key] != null; + +Map _preferredScalars( + Map data, + Set hidden, +) => { + for (final key in _preferredFields) + 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( + Map data, + Set hidden, +) { + final fallback = {}; + for (final entry in data.entries) { + if (fallback.length >= 5) break; + if (_usable(data, hidden, 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, + UnitFormatter units, + 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, qualityUnitFormattersFor(units)); + } on ArgumentError catch (e) { + _log.warning('Conflict preview could not read a finding row', error: e); + return null; + } on FormatException catch (e) { + _log.warning('Conflict preview could not read a finding row', error: e); + return null; + } 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 new file mode 100644 index 0000000000..18a29aae69 --- /dev/null +++ b/lib/features/settings/presentation/widgets/conflict_reference_labels.dart @@ -0,0 +1,138 @@ +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; 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, + 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 ?? 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. +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..e01c6283fa 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,12 @@ class _ConflictResolutionDialogState ], ), const SizedBox(height: 8), - _buildDataPreview(context, conflict.localData), + ConflictDataPreview( + entityType: conflict.entityType, + data: conflict.localData, + references: conflict.localReferences, + counterpart: conflict.remoteData, + ), ], ), ), @@ -230,7 +237,12 @@ class _ConflictResolutionDialogState ], ), const SizedBox(height: 8), - _buildDataPreview(context, conflict.remoteData), + ConflictDataPreview( + entityType: conflict.entityType, + data: conflict.remoteData, + references: conflict.remoteReferences, + counterpart: conflict.localData, + ), ], ), ), @@ -239,79 +251,24 @@ 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(), - ); + /// 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 = _ownName(conflict.localData) ?? _ownName(conflict.remoteData); + if (own != null) return own; + return conflictReferenceSummary(conflict.localReferences) ?? + conflictReferenceSummary(conflict.remoteReferences) ?? + conflict.displayName; } - 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; + 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) { @@ -444,40 +401,45 @@ 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; } } - 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/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/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/lib/features/statistics/data/dive_filter_sql.dart b/lib/features/statistics/data/dive_filter_sql.dart index bf4e48ec8d..cbc4be8fa3 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(', '); @@ -116,6 +129,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'), + ); + } + // No buddy: neither the legacy scalar column nor a junction-linked buddy // is set, mirroring DiveRepository and DiveFilterState.apply. if (filter.noBuddyOnly == true) { @@ -215,3 +234,49 @@ import 'package:submersion/features/equipment/domain/constants/equipment_attribu params: params, ); } + +/// Recorded deco-signal SQL condition (no bind params), shared by +/// [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 +/// 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. +/// +/// 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). +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/features/statistics/presentation/pages/records_page.dart b/lib/features/statistics/presentation/pages/records_page.dart index b14d737b60..cc456033ed 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), + ), + ], + ), ), - ], + ), ), - ), + ], ), ); } @@ -60,19 +74,33 @@ 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 + // 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 +108,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/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/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/lib/features/universal_import/data/models/import_enums.dart b/lib/features/universal_import/data/models/import_enums.dart index 2604c22706..d317393c3b 100644 --- a/lib/features/universal_import/data/models/import_enums.dart +++ b/lib/features/universal_import/data/models/import_enums.dart @@ -270,7 +270,8 @@ enum ImportEntityType { courses, tags, diveTypes, - serviceRecords; + serviceRecords, + media; String get displayName => switch (this) { dives => 'Dives', @@ -285,6 +286,7 @@ enum ImportEntityType { tags => 'Tags', diveTypes => 'Dive Types', serviceRecords => 'Service Records', + media => 'Photos', }; String get shortName => switch (this) { @@ -300,5 +302,6 @@ enum ImportEntityType { tags => 'Tags', diveTypes => 'Types', serviceRecords => 'Service', + media => 'Photos', }; } 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..f9319d15e1 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,64 @@ 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; + } + + // Same parser the elements use: it accepts a comma + // separator and rejects NaN and out-of-range pairs. + final gps = _parseGps(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 `` elements from a `` into profile points. /// /// Subsurface only records tank pressure on a subset of samples (when the 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/domain/services/import_media_resolver.dart b/lib/features/universal_import/domain/services/import_media_resolver.dart new file mode 100644 index 0000000000..3800f5facd --- /dev/null +++ b/lib/features/universal_import/domain/services/import_media_resolver.dart @@ -0,0 +1,161 @@ +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. +String foreignBasename(String path) { + final index = path.lastIndexOf(RegExp(r'[/\\]')); + return index < 0 ? path : path.substring(index + 1); +} 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..65ebdca289 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(), @@ -401,6 +405,49 @@ class UniversalImportNotifier extends StateNotifier { state = state.copyWith(wasLoadedExternally: true); } + /// 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, + ); + } + /// Desktop only: pick a folder and recursively gather importable files. Future pickFolder() async { state = state.copyWith( 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/lib/features/universal_import/presentation/widgets/import_summary_step.dart b/lib/features/universal_import/presentation/widgets/import_summary_step.dart index 76810fb320..38aff542bb 100644 --- a/lib/features/universal_import/presentation/widgets/import_summary_step.dart +++ b/lib/features/universal_import/presentation/widgets/import_summary_step.dart @@ -73,6 +73,7 @@ class ImportSummaryStep extends ConsumerWidget { ImportEntityType.tags => Icons.label_outline, ImportEntityType.diveTypes => Icons.category_outlined, ImportEntityType.serviceRecords => Icons.handyman_outlined, + ImportEntityType.media => Icons.photo_library_outlined, }; } } 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/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 31a1eea7b6..56c46c16f3 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, plural, =1{دقيقة واحدة} =2{دقيقتين} other{{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": { @@ -429,6 +439,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": "عرض التفاصيل", @@ -1624,6 +1635,7 @@ "diveImport_uddf_tabTrips": "الرحلات", "diveImport_uddf_tabTypes": "الأنواع", "diveImport_uddf_tags": "الوسوم", + "diveImport_uddf_media": "الصور", "diveImport_uddf_title": "استيراد من UDDF", "diveImport_uddf_toggleDiveSelection": "تبديل تحديد الغطسة", "diveImport_uddf_toggleEntitySelection": "تبديل تحديد {name}", @@ -2047,6 +2059,7 @@ "diveLog_filter_clearAll": "مسح الكل", "diveLog_filter_clearDates": "مسح التواريخ", "diveLog_filter_clearRating": "مسح تصفية التقييم", + "diveLog_filter_clearWeekdays": "مسح أيام الأسبوع", "diveLog_filter_dateSeparator": "إلى", "diveLog_filter_endDate": "تاريخ الانتهاء", "diveLog_filter_errorLoadingSites": "خطأ في تحميل المواقع", @@ -2072,6 +2085,7 @@ "diveLog_filter_sectionGasMix": "خليط الغاز (O₂%)", "diveLog_filter_sectionMinRating": "الحد الأدنى للتقييم", "diveLog_filter_sectionTags": "الوسوم", + "diveLog_filter_sectionWeekdays": "أيام الأسبوع", "diveLog_filter_showOnlyFavorites": "عرض الغوصات المفضلة فقط", "diveLog_filter_showOnlyNoBuddy": "عرض الغوصات بدون زميل غوص فقط", "diveLog_filter_startDate": "تاريخ البدء", @@ -2363,7 +2377,9 @@ "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)", "diveLog_search_label_diveCenter": "مركز الغوص", "diveLog_search_label_diveSite": "موقع غوص", @@ -2725,7 +2741,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 +2749,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": "مثال: تيارات قوية، حركة قوارب، قناديل بحر، شعاب مرجانية حادة", @@ -2831,9 +2854,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}", @@ -3779,6 +3802,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": "المسارات المسجّلة", @@ -4130,6 +4156,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": "درجة الحرارة", @@ -4430,6 +4457,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": "معدل ضربات القلب", @@ -4600,6 +4628,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": "حل التعارضات", @@ -5384,6 +5448,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} غوصة}} إجمالاً. لا يمكن التراجع عن هذا الإجراء.", @@ -7214,6 +7285,7 @@ }, "diveLog_detail_altitudeMismatch_title": "موقع الغوص على ارتفاع", "diveLog_detail_altitudeMismatch_subtitle": "هذا الموقع مسجل له ارتفاع لكن الغطسة بلا ارتفاع، لذا افترض تحليل تخفيف الضغط مستوى سطح البحر. عيّن ارتفاع الغطسة للتصحيح.", + "diveLog_detail_sacVolumeHint": "أضف حجم الأسطوانة لعرض معدل SAC بوحدة {unit}/min", "emergencyCard_title": "الطوارئ", "emergencyCard_callDan": "اتصل بـ {name}", "@emergencyCard_callDan": { @@ -7554,6 +7626,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": "درجات ودقائق وثوانٍ", @@ -9631,6 +9705,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 cce9cd6bff..d208536a2f 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, 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", + "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": { @@ -429,6 +439,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", @@ -1624,6 +1635,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", @@ -2047,6 +2059,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 +2085,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", @@ -2363,7 +2377,9 @@ "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)", "diveLog_search_label_diveCenter": "Tauchbasis", "diveLog_search_label_diveSite": "Tauchplatz", @@ -2725,7 +2741,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 +2749,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", @@ -2831,9 +2854,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}", @@ -3779,6 +3802,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", @@ -4130,6 +4156,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", @@ -4430,6 +4457,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", @@ -4600,6 +4628,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", @@ -5384,6 +5448,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.", @@ -7214,6 +7285,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": { @@ -7554,6 +7626,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", @@ -9631,6 +9705,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 7f81814529..cea05da425 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." @@ -3201,6 +3205,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 +3231,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", @@ -3791,7 +3797,9 @@ "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)", "diveLog_search_label_diveCenter": "Dive Center", "diveLog_search_label_diveSite": "Dive Site", @@ -4801,7 +4809,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 +4817,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", @@ -4907,9 +4922,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}", @@ -4935,6 +4950,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, plural, =1{1 minute} other{{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", @@ -5140,6 +5198,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 +5209,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 +5217,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" @@ -7235,6 +7296,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", @@ -8668,6 +8730,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", @@ -8960,6 +9026,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", @@ -9550,6 +9652,16 @@ } } }, + "@settings_conflict_ref_named": { + "placeholders": { + "name": { + "type": "Object" + }, + "date": { + "type": "Object" + } + } + }, "@settings_conflict_errorLoading": { "placeholders": { "error": { @@ -10877,6 +10989,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", @@ -13388,6 +13544,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}", @@ -13641,6 +13801,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": { @@ -15410,6 +15573,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": { @@ -16084,6 +16248,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", @@ -18516,6 +18682,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", @@ -18637,5 +18846,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 5aaa777f1a..d711cac564 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 {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", + "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": { @@ -429,6 +439,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", @@ -1624,6 +1635,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}", @@ -2047,6 +2059,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 +2085,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", @@ -2363,7 +2377,9 @@ "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)", "diveLog_search_label_diveCenter": "Centro de buceo", "diveLog_search_label_diveSite": "Punto de buceo", @@ -2725,7 +2741,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 +2749,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", @@ -2831,9 +2854,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}", @@ -3779,6 +3802,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", @@ -4130,6 +4156,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", @@ -4430,6 +4457,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", @@ -4600,6 +4628,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", @@ -5384,6 +5448,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.", @@ -7214,6 +7285,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": { @@ -7554,6 +7626,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", @@ -9631,6 +9705,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 e7cb4d74b9..8ddb57be94 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, 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", + "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": { @@ -429,6 +439,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", @@ -1551,6 +1562,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}", @@ -1974,6 +1986,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 +2012,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", @@ -2290,7 +2304,9 @@ "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)", "diveLog_search_label_diveCenter": "Centre de plongee", "diveLog_search_label_diveSite": "Site de plongee", @@ -2652,7 +2668,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 +2676,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", @@ -2758,9 +2781,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}", @@ -3706,6 +3729,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", @@ -4057,6 +4083,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", @@ -4357,6 +4384,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", @@ -4527,6 +4555,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", @@ -5311,6 +5375,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.", @@ -7214,6 +7285,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": { @@ -7554,6 +7626,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", @@ -9631,6 +9705,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 2e2e7487e9..b208b28a38 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, plural, =1{כדקה} other{כ-{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": { @@ -429,6 +439,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": "הצגת פרטים", @@ -1551,6 +1562,7 @@ "diveImport_uddf_tabTrips": "טיולים", "diveImport_uddf_tabTypes": "סוגים", "diveImport_uddf_tags": "תגיות", + "diveImport_uddf_media": "תמונות", "diveImport_uddf_title": "ייבוא מ-UDDF", "diveImport_uddf_toggleDiveSelection": "החלף בחירת צלילה", "diveImport_uddf_toggleEntitySelection": "החלף בחירה עבור {name}", @@ -1974,6 +1986,7 @@ "diveLog_filter_clearAll": "ניקוי הכל", "diveLog_filter_clearDates": "ניקוי תאריכים", "diveLog_filter_clearRating": "ניקוי מסנן דירוג", + "diveLog_filter_clearWeekdays": "ניקוי ימי השבוע", "diveLog_filter_dateSeparator": "עד", "diveLog_filter_endDate": "תאריך סיום", "diveLog_filter_errorLoadingSites": "שגיאה בטעינת אתרים", @@ -1999,6 +2012,7 @@ "diveLog_filter_sectionGasMix": "תערובת גזים (O₂%)", "diveLog_filter_sectionMinRating": "דירוג מינימלי", "diveLog_filter_sectionTags": "תגיות", + "diveLog_filter_sectionWeekdays": "ימי השבוע", "diveLog_filter_showOnlyFavorites": "הצגת צלילות מועדפות בלבד", "diveLog_filter_showOnlyNoBuddy": "הצגת צלילות ללא שותף בלבד", "diveLog_filter_startDate": "תאריך התחלה", @@ -2290,7 +2304,9 @@ "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)", "diveLog_search_label_diveCenter": "מרכז צלילה", "diveLog_search_label_diveSite": "אתר צלילה", @@ -2652,7 +2668,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 +2676,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": "לדוגמה, זרמים חזקים, תנועת סירות, מדוזות, אלמוגים חדים", @@ -2758,9 +2781,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}", @@ -3706,6 +3729,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": "מסלולים שהוקלטו", @@ -4057,6 +4083,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": "טמפרטורה", @@ -4434,6 +4461,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": "קצב לב", @@ -4604,6 +4632,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": "פתרון התנגשויות", @@ -5384,6 +5448,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} צלילות}} בסך הכל. לא ניתן לבטל פעולה זו.", @@ -7214,6 +7285,7 @@ }, "diveLog_detail_altitudeMismatch_title": "אתר הצלילה נמצא בגובה", "diveLog_detail_altitudeMismatch_subtitle": "לאתר זה רשום גובה אך לצלילה אין, ולכן ניתוח הדקומפרסיה הניח גובה פני הים. הגדר את גובה הצלילה כדי לתקן.", + "diveLog_detail_sacVolumeHint": "הוסף נפח בלון כדי להציג קצב SAC ב-{unit}/min", "emergencyCard_title": "חירום", "emergencyCard_callDan": "התקשר אל {name}", "@emergencyCard_callDan": { @@ -7554,6 +7626,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": "מעלות, דקות, שניות", @@ -9631,6 +9705,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 9a3b21996b..65a3006bfb 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": { @@ -429,6 +439,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", @@ -1551,6 +1562,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", @@ -1974,6 +1986,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 +2012,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", @@ -2290,7 +2304,9 @@ "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)", "diveLog_search_label_diveCenter": "Merulokozpont", "diveLog_search_label_diveSite": "Merulohely", @@ -2652,7 +2668,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 +2676,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", @@ -2758,9 +2781,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}", @@ -3706,6 +3729,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", @@ -4057,6 +4083,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", @@ -4357,6 +4384,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", @@ -4527,6 +4555,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", @@ -5311,6 +5375,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.", @@ -7214,6 +7285,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": { @@ -7554,6 +7626,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", @@ -9631,6 +9705,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": "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", + "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 d7ad05eb0b..040af7a25f 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, 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à", + "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": { @@ -429,6 +439,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", @@ -1551,6 +1562,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}", @@ -1974,6 +1986,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 +2012,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", @@ -2290,7 +2304,9 @@ "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)", "diveLog_search_label_diveCenter": "Centro immersioni", "diveLog_search_label_diveSite": "Sito di immersione", @@ -2652,7 +2668,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 +2676,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", @@ -2758,9 +2781,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}", @@ -3706,6 +3729,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", @@ -4057,6 +4083,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", @@ -4357,6 +4384,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", @@ -4527,6 +4555,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", @@ -5307,6 +5371,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.", @@ -7214,6 +7285,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": { @@ -7554,6 +7626,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", @@ -9631,6 +9705,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 f56d466905..a38cc77e3f 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: @@ -9599,6 +9605,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 +9761,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: @@ -11903,12 +11921,24 @@ 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: /// **'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: @@ -13942,7 +13972,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 +14017,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: @@ -14579,22 +14651,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. @@ -14747,6 +14819,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, plural, =1{1 minute} other{{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: @@ -21946,6 +22078,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: @@ -24502,6 +24640,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: @@ -25549,6 +25693,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: @@ -30082,6 +30442,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: @@ -35033,6 +35435,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: @@ -35488,6 +35896,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: @@ -40076,6 +40502,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: @@ -41660,6 +42092,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: @@ -54190,6 +54634,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 c0bb6cb400..e26ca196cd 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 => 'مشترك مع جميع ملفات الغوص'; @@ -5663,6 +5667,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 +5745,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'الوسوم'; + @override + String get diveLog_filter_sectionWeekdays => 'أيام الأسبوع'; + @override String get diveLog_filter_showOnlyFavorites => 'عرض الغوصات المفضلة فقط'; @@ -6939,9 +6949,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'تخفيف الضغط'; + @override String get diveLog_search_label_depthRange => 'نطاق العمق (m)'; @@ -8098,7 +8114,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'اختر طريقة تحديد الموقع - سيتم ملء الدولة والمنطقة تلقائياً'; + 'اختر طريقة لتحديد الموقع أو ابحث عن الإحداثيات لملء البلد والمنطقة والبلدة والمسطح المائي تلقائيًا'; @override String get diveSites_edit_gps_latitude_hint => 'مثال: 21.4225'; @@ -8121,6 +8137,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 => 'استخدام موقعي'; @@ -8477,17 +8517,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 @@ -8596,6 +8636,60 @@ 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: + 'موقع غوص واحد له إحداثيات ينقصه البلد أو المنطقة أو البلدة أو المسطح المائي.', + ); + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutes دقائق', + two: 'دقيقتين', + one: 'دقيقة واحدة', + ); + return '$_temp0 سيبحث Submersion عن كل منها في OpenStreetMap ويملأ الحقول الفارغة فقط. يستغرق ذلك نحو $_temp1.'; + } + + @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 => 'رجوع'; @@ -12770,6 +12864,10 @@ class AppLocalizationsAr extends AppLocalizations { String get media_writeMetadata_keepOriginalVideo => 'الاحتفاظ بالفيديو الأصلي'; + @override + String get media_writeMetadata_livePhotoUnsupported => + 'صور Live Photos غير مدعومة بعد. كرّر هذه الصورة كصورة ثابتة، ثم اكتب بيانات الغوص في النسخة.'; + @override String get media_writeMetadata_noDataAvailable => 'لا توجد بيانات غوص متاحة للكتابة.'; @@ -14273,6 +14371,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 => 'كثافة الغاز'; @@ -14974,6 +15076,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 => 'النسخة البعيدة'; @@ -17669,6 +17882,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( @@ -20666,6 +20915,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diveImport_uddf_tags => 'الوسوم'; + @override + String get diveImport_uddf_media => 'الصور'; + @override String get diveImport_uddf_title => 'استيراد من UDDF'; @@ -20970,6 +21222,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( @@ -23602,6 +23863,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'; @@ -24578,6 +24844,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 => 'درجات عشرية'; @@ -32491,6 +32764,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 90520ce6d8..25103c633f 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'; @@ -5781,6 +5785,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 +5864,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'; @@ -7082,9 +7092,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Dekompression'; + @override String get diveLog_search_label_depthRange => 'Tiefenbereich (m)'; @@ -8248,7 +8264,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 +8287,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'; @@ -8633,17 +8674,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 @@ -8755,6 +8796,60 @@ 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.', + ); + 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 + 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'; @@ -12999,6 +13094,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.'; @@ -14509,6 +14608,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'; @@ -15225,6 +15328,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'; @@ -17964,6 +18179,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( @@ -21010,6 +21261,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'; @@ -21313,6 +21567,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( @@ -23984,6 +24247,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'; @@ -24981,6 +25249,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'; @@ -32733,6 +33008,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 5f98918bde..8bdd1c5498 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'; @@ -5677,6 +5680,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 +5758,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'; @@ -6953,9 +6962,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Decompression'; + @override String get diveLog_search_label_depthRange => 'Depth Range (m)'; @@ -8115,7 +8130,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 +8153,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'; @@ -8492,17 +8532,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 @@ -8611,6 +8651,60 @@ 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.', + ); + 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 + 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'; @@ -12793,6 +12887,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.'; @@ -14292,6 +14390,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'; @@ -14991,6 +15093,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'; @@ -17687,6 +17900,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( @@ -20687,6 +20936,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'; @@ -20989,6 +21241,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( @@ -23625,6 +23886,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'; @@ -24605,6 +24871,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'; @@ -32285,6 +32558,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 aeb5e0f185..cad71c0ce5 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'; @@ -5786,6 +5790,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 +5870,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'; @@ -7088,9 +7098,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Descompresión'; + @override String get diveLog_search_label_depthRange => 'Rango de profundidad (m)'; @@ -8258,7 +8274,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 +8297,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'; @@ -8642,17 +8684,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 @@ -8762,6 +8804,61 @@ 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.', + ); + 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 + 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'; @@ -12994,6 +13091,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.'; @@ -14520,6 +14621,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'; @@ -15238,6 +15343,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'; @@ -18005,6 +18225,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( @@ -21057,6 +21313,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'; @@ -21366,6 +21625,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( @@ -24042,6 +24310,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'; @@ -25035,6 +25308,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'; @@ -32838,6 +33119,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 44cc51ffef..855134e1a7 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'; @@ -5806,6 +5810,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 +5890,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'; @@ -7114,9 +7124,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Décompression'; + @override String get diveLog_search_label_depthRange => 'Plage de profondeur (m)'; @@ -8290,7 +8306,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 +8329,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'; @@ -8674,17 +8716,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 @@ -8794,6 +8836,61 @@ 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.', + ); + 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 + 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'; @@ -13046,6 +13143,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.'; @@ -14569,6 +14670,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'; @@ -15286,6 +15391,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'; @@ -18064,6 +18282,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( @@ -21115,6 +21370,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'; @@ -21424,6 +21682,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( @@ -24099,6 +24366,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'; @@ -25098,6 +25370,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'; @@ -32884,6 +33163,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 2494f4d7b0..5b889b9be9 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 => 'משותף עם כל פרופילי הצלילה'; @@ -5634,6 +5638,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 +5716,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveLog_filter_sectionTags => 'תגיות'; + @override + String get diveLog_filter_sectionWeekdays => 'ימי השבוע'; + @override String get diveLog_filter_showOnlyFavorites => 'הצגת צלילות מועדפות בלבד'; @@ -6903,9 +6913,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'דקומפרסיה'; + @override String get diveLog_search_label_depthRange => 'טווח עומק (m)'; @@ -8053,7 +8069,7 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveSites_edit_gps_helperText => - 'בחר שיטת מיקום - הקואורדינטות ימלאו אוטומטית את המדינה והאזור'; + 'בחרו שיטת מיקום או חפשו את הקואורדינטות כדי למלא אוטומטית מדינה, אזור, עיר וגוף מים'; @override String get diveSites_edit_gps_latitude_hint => 'לדוגמה, 21.4225'; @@ -8076,6 +8092,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 => 'השתמש במיקום שלי'; @@ -8427,17 +8468,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 @@ -8545,6 +8586,57 @@ 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: 'לאתר אחד עם קואורדינטות חסרים מדינה, אזור, עיר או גוף מים.', + ); + String _temp1 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: 'כ-$minutes דקות', + one: 'כדקה', + ); + return '$_temp0 Submersion יחפש כל אחד מהם ב-OpenStreetMap וימלא רק שדות ריקים. זה נמשך $_temp1.'; + } + + @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 => 'חזרה'; @@ -12685,6 +12777,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 => 'אין נתוני צלילה זמינים לכתיבה.'; @@ -14177,6 +14273,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 => 'צפיפות גז'; @@ -14866,6 +14966,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 => 'גרסה מרוחקת'; @@ -17537,6 +17748,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( @@ -20519,6 +20766,9 @@ class AppLocalizationsHe extends AppLocalizations { @override String get diveImport_uddf_tags => 'תגיות'; + @override + String get diveImport_uddf_media => 'תמונות'; + @override String get diveImport_uddf_title => 'ייבוא מ-UDDF'; @@ -20817,6 +21067,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( @@ -23434,6 +23693,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'; @@ -24400,6 +24664,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 => 'מעלות עשרוניות'; @@ -32151,6 +32422,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 732d13a38e..707b7e5c85 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'; @@ -5765,6 +5768,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 +5847,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'; @@ -7063,9 +7072,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Dekompresszio'; + @override String get diveLog_search_label_depthRange => 'Melyseg tartomany (m)'; @@ -8239,7 +8254,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 +8277,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'; @@ -8622,17 +8662,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 @@ -8741,6 +8781,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'; @@ -12967,6 +13055,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.'; @@ -14479,6 +14571,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'; @@ -15195,6 +15291,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'; @@ -17943,6 +18152,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( @@ -20978,6 +21223,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'; @@ -21283,6 +21531,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( @@ -23943,6 +24200,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'; @@ -24936,6 +25198,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'; @@ -32679,6 +32948,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 => 'mm:ss'; + + @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 080ca4d3d5..cc2f49c36e 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'; @@ -5783,6 +5787,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 +5868,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'; @@ -7087,9 +7097,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Decompressione'; + @override String get diveLog_search_label_depthRange => 'Intervallo profondita (m)'; @@ -8258,7 +8274,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 +8298,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'; @@ -8642,17 +8684,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 @@ -8761,6 +8803,61 @@ 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.', + ); + 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 + 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'; @@ -13009,6 +13106,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.'; @@ -14526,6 +14627,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'; @@ -15245,6 +15350,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'; @@ -17999,6 +18216,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( @@ -21040,6 +21294,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'; @@ -21348,6 +21605,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( @@ -24025,6 +24291,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'; @@ -25021,6 +25292,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'; @@ -32795,6 +33073,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 7bd4220c1d..013ad2b850 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'; @@ -5741,6 +5745,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 +5823,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'; @@ -7029,9 +7039,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Decompressie'; + @override String get diveLog_search_label_depthRange => 'Dieptebereik (m)'; @@ -8191,7 +8207,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 +8230,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'; @@ -8573,17 +8614,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 @@ -8692,6 +8733,60 @@ 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.', + ); + 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 + 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'; @@ -12909,6 +13004,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.'; @@ -14418,6 +14517,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'; @@ -15128,6 +15231,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'; @@ -17852,6 +18066,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( @@ -20881,6 +21131,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'; @@ -21183,6 +21436,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( @@ -23847,6 +24109,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'; @@ -24838,6 +25105,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'; @@ -32574,6 +32848,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 3b79720ce1..4d6c424a75 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'; @@ -5788,6 +5792,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 +5872,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'; @@ -7087,9 +7097,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => 'Descompressão'; + @override String get diveLog_search_label_depthRange => 'Faixa de Profundidade (m)'; @@ -8257,7 +8273,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 +8296,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'; @@ -8644,17 +8686,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 @@ -8764,6 +8806,61 @@ 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.', + ); + 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 + 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'; @@ -13006,6 +13103,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.'; @@ -14525,6 +14626,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'; @@ -15243,6 +15348,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'; @@ -18005,6 +18223,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( @@ -21042,6 +21296,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'; @@ -21350,6 +21607,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( @@ -24023,6 +24289,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'; @@ -25016,6 +25287,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'; @@ -32810,6 +33088,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 a709296041..6ca55d48fd 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 => '已与所有潜水员资料共享'; @@ -5492,6 +5495,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 +5573,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveLog_filter_sectionTags => '标签'; + @override + String get diveLog_filter_sectionWeekdays => '星期'; + @override String get diveLog_filter_showOnlyFavorites => '仅显示收藏的潜水'; @@ -6733,9 +6742,15 @@ 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₂)'; + @override + String get diveLog_search_label_deco => '减压'; + @override String get diveLog_search_label_depthRange => '深度范围(米)'; @@ -7867,7 +7882,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 +7905,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 => '使用我的位置'; @@ -8232,17 +8268,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 @@ -8348,6 +8384,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 => '返回'; @@ -12408,6 +12486,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get media_writeMetadata_keepOriginalVideo => '保留原始视频'; + @override + String get media_writeMetadata_livePhotoUnsupported => + '尚不支持实况照片。请将其复制为静态照片,然后将潜水数据写入副本。'; + @override String get media_writeMetadata_noDataAvailable => '没有可写入的潜水数据。'; @@ -13844,6 +13926,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 => '气体密度'; @@ -14512,6 +14597,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 => '远程版本'; @@ -17076,6 +17271,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( @@ -19983,6 +20213,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get diveImport_uddf_tags => '标签'; + @override + String get diveImport_uddf_media => '照片'; + @override String get diveImport_uddf_title => '从 UDDF 导入'; @@ -20270,6 +20503,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( @@ -22832,6 +23074,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'; @@ -23766,6 +24013,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 => '十进制度'; @@ -30916,6 +31170,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 6e13d12895..773287af3d 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, 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", + "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": { @@ -429,6 +439,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", @@ -1624,6 +1635,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}", @@ -2047,6 +2059,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 +2085,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", @@ -2363,7 +2377,9 @@ "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)", "diveLog_search_label_diveCenter": "Duikcentrum", "diveLog_search_label_diveSite": "Duikstek", @@ -2725,7 +2741,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 +2749,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", @@ -2831,9 +2854,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}", @@ -3779,6 +3802,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", @@ -4130,6 +4156,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", @@ -4430,6 +4457,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", @@ -4600,6 +4628,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", @@ -5384,6 +5448,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.", @@ -7214,6 +7285,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": { @@ -7554,6 +7626,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", @@ -9631,6 +9705,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 1860259470..5e162a5d8a 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, 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", + "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": { @@ -429,6 +439,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", @@ -1624,6 +1635,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}", @@ -2047,6 +2059,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 +2085,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", @@ -2363,7 +2377,9 @@ "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)", "diveLog_search_label_diveCenter": "Centro de Mergulho", "diveLog_search_label_diveSite": "Ponto de Mergulho", @@ -2725,7 +2741,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 +2749,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", @@ -2831,9 +2854,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}", @@ -3779,6 +3802,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", @@ -4130,6 +4156,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", @@ -4430,6 +4457,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", @@ -4600,6 +4628,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", @@ -5384,6 +5448,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.", @@ -7214,6 +7285,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": { @@ -7554,6 +7626,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", @@ -9631,6 +9705,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 141583486e..6b3a3155a9 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": { @@ -523,6 +533,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": "查看详情", @@ -1718,6 +1729,7 @@ "diveImport_uddf_tabTrips": "旅行", "diveImport_uddf_tabTypes": "类型", "diveImport_uddf_tags": "标签", + "diveImport_uddf_media": "照片", "diveImport_uddf_title": "从 UDDF 导入", "diveImport_uddf_toggleDiveSelection": "切换潜水记录选择", "diveImport_uddf_toggleEntitySelection": "切换 {name} 的选择", @@ -2178,6 +2190,7 @@ "diveLog_filter_clearAll": "清除全部", "diveLog_filter_clearDates": "清除日期", "diveLog_filter_clearRating": "清除评分筛选", + "diveLog_filter_clearWeekdays": "清除星期筛选", "diveLog_filter_dateSeparator": "至", "diveLog_filter_endDate": "结束日期", "diveLog_filter_errorLoadingSites": "加载潜水点出错", @@ -2203,6 +2216,7 @@ "diveLog_filter_sectionGasMix": "气体混合 (O₂%)", "diveLog_filter_sectionMinRating": "最低评分", "diveLog_filter_sectionTags": "标签", + "diveLog_filter_sectionWeekdays": "星期", "diveLog_filter_showOnlyFavorites": "仅显示收藏的潜水", "diveLog_filter_showOnlyNoBuddy": "仅显示无潜伴的潜水", "diveLog_filter_startDate": "开始日期", @@ -2496,7 +2510,9 @@ "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": "深度范围(米)", "diveLog_search_label_diveCenter": "潜水中心", "diveLog_search_label_diveSite": "潜水点", @@ -2858,7 +2874,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 +2882,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": "例如:强水流、船只交通、水母、尖锐珊瑚", @@ -2964,9 +2987,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}", @@ -3924,6 +3947,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": "已记录的轨迹", @@ -4013,6 +4039,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": "正北朝上", @@ -4285,6 +4318,7 @@ "media_writeMetadata_diveTimeLabel": "潜水时间", "media_writeMetadata_gpsLabel": "GPS", "media_writeMetadata_keepOriginalVideo": "保留原始视频", + "media_writeMetadata_livePhotoUnsupported": "尚不支持实况照片。请将其复制为静态照片,然后将潜水数据写入副本。", "media_writeMetadata_noDataAvailable": "没有可写入的潜水数据。", "media_writeMetadata_siteLabel": "潜水点", "media_writeMetadata_temperatureLabel": "温度", @@ -4586,6 +4620,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": "心率", @@ -4757,6 +4792,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": "解决冲突", @@ -7214,6 +7285,7 @@ }, "diveLog_detail_altitudeMismatch_title": "潜点位于高海拔", "diveLog_detail_altitudeMismatch_subtitle": "该潜点记录了海拔,但此次潜水未设置海拔,因此减压分析按海平面计算。请设置潜水海拔以更正。", + "diveLog_detail_sacVolumeHint": "添加气瓶容积以按 {unit}/min 显示气体消耗率", "emergencyCard_title": "紧急情况", "emergencyCard_callDan": "呼叫 {name}", "@emergencyCard_callDan": { @@ -7554,6 +7626,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": "度分秒", @@ -9631,6 +9705,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/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/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/lib/shared/widgets/master_detail/master_detail_scaffold.dart b/lib/shared/widgets/master_detail/master_detail_scaffold.dart index 6a9e2b59d7..9f8aa0f746 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,27 @@ 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; + +/// 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). +final masterPaneWidthProvider = StateProvider( + (ref) => kMasterPaneWidth, +); + /// Mode for the detail pane in master-detail layout. enum DetailPaneMode { /// Viewing item details (default) @@ -310,49 +332,71 @@ 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 - + _kResizeHandleWidth) + .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 +427,32 @@ 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 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)), + ), + ), + ), + ); + } +} + /// Container for the master (list) pane with optional FAB. class _MasterPane extends StatelessWidget { final Widget child; 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/macos/fastlane/Fastfile b/macos/fastlane/Fastfile index 0e9c5c3fa8..7642ab4933 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 @@ -679,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..5965db55c4 --- /dev/null +++ b/macos/fastlane/metadata/review_information/notes.txt @@ -0,0 +1,9 @@ +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. + +"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. + +"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, 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/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.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/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_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(); 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/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 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)" 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 ------------------------------------------------ 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/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/core/constants/place_name_language_test.dart b/test/core/constants/place_name_language_test.dart new file mode 100644 index 0000000000..9a6fd9fb87 --- /dev/null +++ b/test/core/constants/place_name_language_test.dart @@ -0,0 +1,40 @@ +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 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'); + expect(PlaceNameLanguage.normalize(''), 'en'); + expect(PlaceNameLanguage.normalize('system'), 'en'); + }); +} 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/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/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..9e48ed85b5 --- /dev/null +++ b/test/core/database/migration_v163_estimated_tank_pressure_default_test.dart @@ -0,0 +1,128 @@ +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 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)); + 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/database/migration_v164_media_manual_elapsed_test.dart b/test/core/database/migration_v164_media_manual_elapsed_test.dart new file mode 100644 index 0000000000..61a0490505 --- /dev/null +++ b/test/core/database/migration_v164_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'; + +/// v164 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-v164 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('v164 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(164)); + expect(AppDatabase.migrationVersions, contains(164)); + }); + + 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 v164 gains the column via ' + 'beforeOpen', () async { + // Stamped AT 164 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 = 164'); + 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/core/database/migration_v166_place_name_language_test.dart b/test/core/database/migration_v166_place_name_language_test.dart new file mode 100644 index 0000000000..e21d0203a3 --- /dev/null +++ b/test/core/database/migration_v166_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-v166 shape: a diver_settings table without the place name +/// language column, stamped at v161 so the 161->166 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("v166 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('v166 is present in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(166)); + expect(AppDatabase.migrationVersions, contains(166)); + }); +} diff --git a/test/core/database/migration_v168_buddy_favorite_test.dart b/test/core/database/migration_v168_buddy_favorite_test.dart new file mode 100644 index 0000000000..9dfa58eff6 --- /dev/null +++ b/test/core/database/migration_v168_buddy_favorite_test.dart @@ -0,0 +1,103 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +/// v168 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('v168 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(168)); + expect(AppDatabase.migrationVersions, contains(168)); + }); + + 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 v168 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/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)); + }); +} 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); 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/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 71da05e3f0..2fd8a7b65d 100644 --- a/test/core/services/location_service_test.dart +++ b/test/core/services/location_service_test.dart @@ -1,134 +1,39 @@ -import 'dart:async'; 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. -/// -/// 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 = '{}'}); - - final int statusCode; - final String 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 _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.body); - } - - @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; + setUp(() { + LocationService.throttle = NominatimThrottle(minimumGap: Duration.zero); + addTearDown(() => LocationService.throttle = NominatimThrottle()); + }); + 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', @@ -137,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'); @@ -148,7 +73,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', @@ -159,7 +84,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'); @@ -171,17 +96,19 @@ 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'}, }), ); - 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(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', ); @@ -200,8 +127,34 @@ 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( + final server = FakeNominatim( body: jsonEncode({ 'address': { 'country': 'Canada', @@ -212,7 +165,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'); @@ -220,7 +173,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', @@ -231,7 +184,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'); @@ -242,11 +195,13 @@ 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'}), ); - 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); @@ -254,26 +209,39 @@ void main() { }, ); - test('returns empty fields on a non-200 response', () async { - final server = _FakeNominatim( + 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', ); final result = await server.run( - () => service.reverseGeocode(36.0143, -5.6044), + () => 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 { - final server = _FakeNominatim(body: 'rate limited'); + 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); @@ -282,21 +250,25 @@ 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)); + await server.run( + () => service.reverseGeocode(36.0143, -5.6044, languageCode: 'en'), + ); 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)', ); }); }); group('forwardGeocode', () { test('returns the parsed coordinates and address details', () async { - final server = _FakeNominatim( + final server = FakeNominatim( body: jsonEncode([ { 'lat': '36.0143', @@ -324,7 +296,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'}, ]), @@ -340,7 +312,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', @@ -363,7 +335,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', @@ -386,7 +358,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'}, ]), @@ -403,7 +375,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'), @@ -413,7 +385,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'}, ]), @@ -425,7 +397,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')); @@ -438,7 +410,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')); @@ -448,7 +420,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(' ')); @@ -471,7 +443,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 +455,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 +474,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( @@ -521,17 +497,23 @@ 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(() => 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, @@ -541,6 +523,205 @@ void main() { expect(geocoding.locales, [const Locale('en'), const Locale('en')]); }); }); + + 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': { + '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 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( + 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); + }); + + 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'); + }); + }); + }); } /// Minimal [GeocodingPlatformFactory] handing out one fake [gpi.Geocoding]. 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/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', () { 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..fff92a6d31 --- /dev/null +++ b/test/core/services/sync/conflict_reference_resolver_test.dart @@ -0,0 +1,258 @@ +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's 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('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', + '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..9e5a4f56e5 100644 --- a/test/core/services/sync/sync_conflict_resolution_test.dart +++ b/test/core/services/sync/sync_conflict_resolution_test.dart @@ -73,6 +73,97 @@ 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('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 { @@ -221,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/core/services/sync/sync_diver_settings_fallback_test.dart b/test/core/services/sync/sync_diver_settings_fallback_test.dart index 6e013cf855..11d8f95f19 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,112 @@ 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); + }, + ); + + 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); + }, + ); + + test( + 'applies a pre-v166 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-166', + diverId: 'diver-1', + createdAt: now, + updatedAt: now, + ), + ); + final exported = await serializer.fetchRecord('diverSettings', 'ds-166'); + final legacy = Map.from(exported!) + ..remove('placeNameLanguage'); + await (db.delete( + db.diverSettings, + )..where((t) => t.id.equals('ds-166'))).go(); + + await serializer.upsertRecord('diverSettings', legacy); + + final row = await (db.select( + db.diverSettings, + )..where((t) => t.id.equals('ds-166'))).getSingle(); + expect(row.placeNameLanguage, 'en'); + }, + ); } diff --git a/test/core/utils/app_version_test.dart b/test/core/utils/app_version_test.dart new file mode 100644 index 0000000000..c6dcf1e885 --- /dev/null +++ b/test/core/utils/app_version_test.dart @@ -0,0 +1,63 @@ +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', () { + 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'); + }); + }); + + 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', + ); + }); + }); +} 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', + ); + } + }); + }); }); } diff --git a/test/features/buddies/data/repositories/buddy_repository_test.dart b/test/features/buddies/data/repositories/buddy_repository_test.dart index 820b733aa3..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(); }); @@ -257,6 +259,108 @@ 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); + }); + + 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', () { test('should return stats with zero dives for new buddy', () async { final buddy = await repository.createBuddy( @@ -426,5 +530,135 @@ 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 buddyId, + String diveId, + int createdAt, + ) async { + final db = DatabaseService.instance.database; + await db.customStatement( + 'UPDATE dive_buddies SET created_at = ? ' + 'WHERE dive_id = ? AND buddy_id = ?', + [createdAt, diveId, buddyId], + ); + } + + 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(buddy.id, 'old', 9000); + await setLinkCreatedAt(buddy.id, 'newest', 8000); + await setLinkCreatedAt(buddy.id, '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(buddy.id, 'earlier', 9000); + await setLinkCreatedAt(buddy.id, '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(buddy.id, 'lower', 9000); + await setLinkCreatedAt(buddy.id, 'higher', 1); + + final diveIds = await repository.getDiveIdsForBuddy(buddy.id); + + expect(diveIds, equals(['higher', 'lower'])); + }, + ); + + 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 { + 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', + ); + }, + ); + }); }); } 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/buddies/presentation/pages/buddy_detail_page_test.dart b/test/features/buddies/presentation/pages/buddy_detail_page_test.dart index fdd72078ca..6c37c09ff1 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'; @@ -12,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( @@ -163,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: [ @@ -183,14 +204,77 @@ void main() { ), ), ); - // Tolerate overflow errors in test layout - final errors = []; - FlutterError.onError = (d) => errors.add(d); await tester.pumpAndSettle(); - FlutterError.onError = FlutterError.presentError; // Should show bottomTime formatted as minutes in dive history 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(); + }); + + // Installed before the first frame: an overflow thrown during + // pumpWidget would otherwise escape the handler. + _ignoreOverflowErrors(); + 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), + ), + ), + ); + await tester.pumpAndSettle(); + + 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..2319429d7a 100644 --- a/test/features/buddies/presentation/providers/buddy_providers_test.dart +++ b/test/features/buddies/presentation/providers/buddy_providers_test.dart @@ -1,13 +1,18 @@ 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'; 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'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; @@ -30,6 +35,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. @@ -47,6 +63,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; @@ -219,5 +254,192 @@ 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); + }); + }); + + // 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. + // 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(); + 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 = ? AND buddy_id = ?', + [diveIds.length - i, diveIds[i], buddy.id], + ); + } + + 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', + ); + }); }); } 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..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 @@ -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,10 @@ 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..256b1a2ae5 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,10 @@ 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 +114,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -115,13 +131,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 +169,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 +207,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 +250,9 @@ void main() { _buildPicker( selectedBuddies: [selectedBuddy], overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -244,7 +268,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -266,7 +292,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -296,7 +324,9 @@ void main() { _buildPicker( selectedBuddies: [selectedBuddy], overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -322,7 +352,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) async => []), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => [], + ), ], ), ); @@ -337,8 +369,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 +393,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 +401,9 @@ void main() { await tester.pumpWidget( _buildPicker( overrides: [ - allBuddiesProvider.overrideWith((ref) => completer.future), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) => completer.future, + ), ], ), ); @@ -384,7 +422,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 +432,19 @@ 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 +483,9 @@ void main() { _buildPicker( onChanged: (buddies) => result = buddies, overrides: [ - allBuddiesProvider.overrideWith((ref) async => _testBuddies), + allBuddiesWithDiveCountProvider.overrideWith( + (ref) async => _testBuddiesWithCount, + ), ], ), ); @@ -457,12 +498,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(); @@ -473,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', + ]); + }); + }); } 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..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 { @@ -707,5 +722,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(); + }); }); } 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..10f6150058 --- /dev/null +++ b/test/features/certifications/presentation/widgets/certification_picker_test.dart @@ -0,0 +1,248 @@ +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'; +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 a tile's own [ListTile] contributes when it is NOT excluded from +/// the semantics tree: its visible title and subtitle, newline-joined. +/// +/// 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 + // 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 { + 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( + 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(find.bySemanticsLabel('PADI Divemaster'), findsOneWidget); + expect( + find.bySemanticsLabel(_visibleTextNode('Divemaster', 'PADI')), + findsNothing, + ); + + handle.dispose(); + }); + }); +} 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')); + }); + }); +} 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_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..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( @@ -114,6 +121,76 @@ 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); + }); + + // `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( 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..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 @@ -1863,6 +1961,116 @@ 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( + '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_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/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..a432b8d2be --- /dev/null +++ b/test/features/dive_computer/presentation/providers/download_default_tank_preset_test.dart @@ -0,0 +1,157 @@ +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'; + +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; + late AppDatabase db; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + db = 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); + }); + + 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_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..3c3983bdb6 --- /dev/null +++ b/test/features/dive_import/data/services/uddf_entity_importer_computer_registration_test.dart @@ -0,0 +1,366 @@ +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( + '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, + // 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'); +} + +/// 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_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..62e6b0edd1 --- /dev/null +++ b/test/features/dive_import/data/services/uddf_entity_importer_language_test.dart @@ -0,0 +1,80 @@ +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); + addTearDown(() => LocationService.throttle = NominatimThrottle()); + 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_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/data/repositories/dive_computer_repository_impl_test.dart b/test/features/dive_log/data/repositories/dive_computer_repository_impl_test.dart index 2a6da42f5e..418c980c0e 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; @@ -845,6 +846,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 @@ -1145,4 +1179,274 @@ 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('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: ' ', + 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'); + }); + }); } 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/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..d407ba4565 --- /dev/null +++ b/test/features/dive_log/data/repositories/dive_repository_deco_filter_test.dart @@ -0,0 +1,175 @@ +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 { + db = await setUpTestDatabase(); + repository = DiveRepository(); + }); + tearDown(() async => tearDownTestDatabase()); + + /// Seeds the five recorded-signal shapes the deco axis has to tell apart. + Future seedDives() async { + await repository.createDive( + domain.Dive( + id: 'stop', + 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: 'noStop', + dateTime: DateTime(2026, 1, 2), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), + ], + ), + ); + await repository.createDive( + 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(), { + 'stop', + 'ceilingOnly', + 'eventOnly', + }); + + final noDecoResults = await repository.getDiveSummaries( + filter: const DiveFilterState(decoOnly: false), + ); + 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('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: 'mine', + diverId: diverA, + dateTime: DateTime(2026, 1, 1), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), + ], + ), + ); + await repository.createDive( + domain.Dive( + id: 'theirs', + diverId: diverB, + dateTime: DateTime(2026, 1, 2), + profile: const [ + domain.DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), + ], + ), + ); + + expect( + 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/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/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..0f62368f68 --- /dev/null +++ b/test/features/dive_log/data/repositories/dive_repository_weekday_filter_test.dart @@ -0,0 +1,67 @@ +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/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/domain/models/dive_filter_state_test.dart b/test/features/dive_log/domain/models/dive_filter_state_test.dart index 6d7b5b6456..6a08ead1a7 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,8 +76,10 @@ void main() { expect(filter.minDepth, isNull); expect(filter.maxDepth, isNull); expect(filter.favoritesOnly, isNull); + expect(filter.decoOnly, 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); @@ -153,6 +156,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 noBuddyOnly is true', () { const filter = DiveFilterState(noBuddyOnly: true); @@ -171,6 +186,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'); @@ -221,6 +242,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 noBuddyOnly', () { const original = DiveFilterState(); @@ -237,6 +274,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 +504,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 = [ @@ -680,6 +788,70 @@ void main() { expect(result.map((d) => d.id), containsAll(['d1', 'd2'])); }); + group('decoOnly axis', () { + // 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: 'deco', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 30, decoType: 2), + ], + ), + _makeDive( + id: 'noDeco', + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 18, decoType: 0), + ], + ), + ]; + + expect( + const DiveFilterState(decoOnly: true).apply(dives).map((d) => d.id), + ['deco', 'noDeco'], + ); + }); + + test('decoOnly still combines with the axes apply() does own', () { + final dives = [ + _makeDive(id: 'shallow', maxDepth: 12), + _makeDive(id: 'deep', maxDepth: 40), + ]; + + expect( + const DiveFilterState( + decoOnly: true, + minDepth: 30, + ).apply(dives).map((d) => d.id), + ['deep'], + ); + }); + }); + group('equipmentAttr axis', () { EquipmentAttribute curated(String key, {String? text, double? num}) => EquipmentAttribute.curated( 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_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, + ]); + }); + }); +} 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); 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..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 @@ -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,63 @@ 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)); + }); + + 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); }); }); @@ -309,4 +411,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); + }); + }); } 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..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 @@ -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'; @@ -9,39 +10,58 @@ 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(); + /// 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, { + Dive? dive, + }) async { + dive ??= reportedDive(); SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -65,16 +85,14 @@ 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)); } + 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 +105,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 +130,151 @@ 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('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, + const AppSettings(sacUnit: SacUnit.pressurePerMin), + dive: reportedDive(volume: null), + ); + + expect(find.text('1.5 bar/min'), findsOneWidget); + 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']}')), + ), + ], + ); + ignoreOverflowErrors(); + + 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 { + 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..2a953f6b7c --- /dev/null +++ b/test/features/dive_log/presentation/pages/dive_detail_sac_segments_hint_test.dart @@ -0,0 +1,317 @@ +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'; +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 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() { + 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, + (i) => DiveProfilePoint( + timestamp: i * 60, + depth: (i < 3 ? i * 8.0 : (5 - i) * 8.0), + ), + ), + tanks: tanks, + ); + } + + ProfileAnalysis analysisWithSacSegments({String? tankId}) { + return ProfileAnalysis.empty().copyWith( + sacSegments: [ + SacSegment( + startTimestamp: 0, + endTimestamp: 300, + avgDepth: 18.0, + minDepth: 0.0, + maxDepth: 24.0, + sacRate: 0.8, + gasConsumed: 4.0, + segmentationType: SacSegmentationType.timeInterval, + tankId: tankId, + ), + ], + ); + } + + Future pumpWith( + WidgetTester tester, { + required Dive dive, + required AppSettings settings, + String? segmentTankId, + }) 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(tankId: segmentTankId), + ), + 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 sacCard(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 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(tanks: const [backGasNoVolume]), + 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('shows no hint once the cylinder has a volume', (tester) async { + await pumpWith( + tester, + dive: diveWithProfile(tanks: const [backGasWithVolume]), + settings: const AppSettings(sacUnit: SacUnit.litersPerMin), + ); + + final card = sacCard(tester); + expect(hintIn(card), findsNothing); + expect(unitIn(card, 'L'), findsWidgets); + }); + + testWidgets('shows no hint under a pressure-per-minute preference', ( + tester, + ) async { + await pumpWith( + tester, + dive: diveWithProfile(tanks: const [backGasNoVolume]), + settings: const AppSettings(sacUnit: SacUnit.pressurePerMin), + ); + + 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 { + // 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); + }); +} 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/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/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/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/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/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/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/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/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_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..3d549949a2 --- /dev/null +++ b/test/features/dive_log/presentation/widgets/weekday_filter_selector_test.dart @@ -0,0 +1,118 @@ +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('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: (_) {}); + + 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/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_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)); + }); +} 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); + }); +} 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_fill_location_test.dart b/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart new file mode 100644 index 0000000000..c1d9b022b2 --- /dev/null +++ b/test/features/dive_sites/presentation/pages/site_edit_fill_location_test.dart @@ -0,0 +1,179 @@ +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, + geocodeUnavailable: place.networkFailed, + ); + + @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); + }); + + 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); + }); +} 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/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/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/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/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/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()), ); 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..d15a26b435 --- /dev/null +++ b/test/features/dive_sites/presentation/widgets/site_filter_sheet_test.dart @@ -0,0 +1,163 @@ +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( + // 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(), + ), + ); +} + +/// 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..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 @@ -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,143 @@ 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, + locale: const Locale('en'), + 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, + locale: const Locale('en'), + 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, + locale: const Locale('en'), + 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, + locale: const Locale('en'), + 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, + locale: const Locale('en'), + 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, + locale: const Locale('en'), + child: const SiteListContent(showAppBar: false), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('16ft+'), findsOneWidget); + }); + }); } class _SiteListSelectionHarness extends StatefulWidget { 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..5dd9c84add --- /dev/null +++ b/test/features/dive_sites/presentation/widgets/site_location_backfill_dialog_test.dart @@ -0,0 +1,210 @@ +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; + + /// Puts the notifier mid-run without going through [start]. + void pretendRunning() => state = const BackfillRunning(done: 1, total: 3); + + @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('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. + 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, + ); + }); + + 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); + }); +} 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..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'), @@ -91,10 +94,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 +125,62 @@ 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('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); 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..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 @@ -122,4 +122,212 @@ 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('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 { + 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/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/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/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..970cf52607 --- /dev/null +++ b/test/features/import_wizard/presentation/widgets/dc_adapter_download_step_reacquire_test.dart @@ -0,0 +1,315 @@ +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( + locale: const Locale('en'), + 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(); + } +} + +/// 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 +// --------------------------------------------------------------------------- + +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); + // 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); + }, + ); + + 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'); + }); + }); +} 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); + }); + }); +} 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/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); + }); + }); } 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/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/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); + }); +} 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 a820e668be..b79d0b670a 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'; @@ -153,23 +153,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; @@ -203,9 +186,28 @@ void main() { ); } - /// Taps the bar's Unlink, which always opens the confirmation. + /// 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 shared bar's overflow, where the destructive action sits. + Future openOverflow(WidgetTester tester) async { + await tester.tap(find.byKey(const ValueKey('selection_overflow'))); + await tester.pumpAndSettle(); + } + + /// Taps Unlink, which always opens the confirmation. Future tapUnlink(WidgetTester tester) async { - await tester.tap(find.byKey(const ValueKey('media_library_unlink'))); + await openOverflow(tester); + await tester.tap(find.byKey(const ValueKey('selection_menu_unlink'))); await tester.pumpAndSettle(); } @@ -229,21 +231,35 @@ 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]); expect(find.text('2 selected'), findsOneWidget); + expect( + find.byKey(const ValueKey('selection_action_share')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('selection_action_move_to_dive')), + findsOneWidget, + ); + // Destructive actions are never one tap away in the shared bar, so the + // library's only one is reached by open-then-choose. + expect( + find.byKey(const ValueKey('selection_action_unlink')), + findsNothing, + ); + + await openOverflow(tester); expect(find.text('Unlink'), findsOneWidget); - expect(find.text('Move to dive'), findsOneWidget); - expect(find.text('Share'), findsOneWidget); // The per-link pair and the separate Delete are gone, and a site-linked - // item in the selection no longer grows a second unlink button. + // item in the selection no longer grows a second unlink entry. expect(find.byIcon(Icons.link_off), findsOneWidget); expect(find.byIcon(Icons.location_off), findsNothing); - expect(find.byIcon(Icons.delete_outline), findsNothing); - expect(find.text('Delete'), findsNothing); + expect( + find.byKey(const ValueKey('selection_delete')), + findsNothing, + reason: 'Delete would claim the source files go too, and they do not', + ); expect(find.text('Unlink from site'), findsNothing); }); @@ -263,12 +279,7 @@ 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 tapUnlink(tester); @@ -289,10 +300,11 @@ void main() { reason: 'the library detaches nothing; it removes', ); expect(mediaRepo.unlinkedFromSite, isEmpty); - 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('cancelling deletes nothing and keeps the selection', ( @@ -303,8 +315,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); + await selectTiles(tester, [0]); await tapUnlink(tester); await tester.tap(find.widgetWithText(TextButton, 'Cancel')); @@ -325,10 +336,7 @@ 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 tapUnlink(tester); @@ -352,8 +360,7 @@ 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 tapUnlink(tester); expect(find.text('Unlink 1 items?'), findsOneWidget); @@ -364,10 +371,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(); @@ -384,12 +392,49 @@ void main() { ); 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.byKey(const ValueKey('media_library_unlink')), findsNothing); + expect(find.text('0 selected'), findsOneWidget); + expect(find.byType(MediaViewerPage), findsNothing); + }); + + // 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('the close button leaves selection mode', (tester) async { @@ -398,21 +443,18 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.byType(MediaLibraryTile).first); - await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('media_library_unlink')), - 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.byKey(const ValueKey('media_library_unlink')), 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); }); }); } 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..07498643df --- /dev/null +++ b/test/features/media/presentation/pages/media_viewer_manual_time_test.dart @@ -0,0 +1,212 @@ +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 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( + 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/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); } 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/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); 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..5615e2fe24 --- /dev/null +++ b/test/features/media/presentation/widgets/set_media_time_dialog_test.dart @@ -0,0 +1,174 @@ +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('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 { + 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()); + }); +} 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); + }); } 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/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/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/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/pages/default_visible_metrics_page_test.dart b/test/features/settings/presentation/pages/default_visible_metrics_page_test.dart index 7f1e5f01b1..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 @@ -25,6 +25,14 @@ 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 + Future setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -36,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(), ), ); @@ -133,4 +145,64 @@ 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); + }); + + 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/section_appearance_page_test.dart b/test/features/settings/presentation/pages/section_appearance_page_test.dart index e107837a3a..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 18" - expect(find.textContaining('of 18'), findsOneWidget); + // on default AppSettings. Find the pattern "X of 22" -- one entry per + // SwitchListTile on DefaultVisibleMetricsPage. + expect(find.textContaining('of 22'), 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..ba744c98a8 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 @@ -528,6 +531,12 @@ 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 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 d414b8142b..7f3b30819e 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -89,6 +89,12 @@ 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 setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override Future setDefaultShowAscentRateLine(bool value) async => state = state.copyWith(defaultShowAscentRateLine: value); @override @@ -154,6 +160,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/providers/debug_log_providers_test.dart b/test/features/settings/presentation/providers/debug_log_providers_test.dart index b0089aeb3d..b3b33c9453 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,58 @@ 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 { + // 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( + (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 +787,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 +803,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 +818,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 +838,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 (_) { 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 new file mode 100644 index 0000000000..cd6054120e --- /dev/null +++ b/test/features/settings/presentation/widgets/conflict_resolution_dialog_test.dart @@ -0,0 +1,508 @@ +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', + exists: !tagMissing, + 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('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()); + + expect(find.text('Blue Hole \u2022 Wreck'), findsOneWidget); + expect(find.text('Dive Tags'), findsOneWidget); + 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("renders a depth in the diver's 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 { + // 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('45min'), findsNWidgets(2)); + expect(find.textContaining('1786556582600'), findsNothing); + 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('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. + // 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 { + 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); + }); +} 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..d03ca72636 --- /dev/null +++ b/test/features/settings/presentation/widgets/conflict_scalar_format_test.dart @@ -0,0 +1,110 @@ +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'; +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; + 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); + + 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('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'); + }); + + 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's 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('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 + // 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'); + // Small and negative, the boundary the absolute-magnitude check adds. + expect(format(units, 'holdTime', -90), '-90'); + }); + }); +} 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..eb8ecc1a2d --- /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', 'Español', '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'); + }); +} 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 { diff --git a/test/features/statistics/data/dive_filter_sql_test.dart b/test/features/statistics/data/dive_filter_sql_test.dart index d42c84cee0..22b9c46938 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, @@ -288,6 +328,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( @@ -306,6 +384,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( 'noBuddyOnly excludes both legacy and junction-linked buddies', () async { @@ -399,6 +508,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'); @@ -553,6 +669,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), diff --git a/test/features/statistics/presentation/pages/records_page_test.dart b/test/features/statistics/presentation/pages/records_page_test.dart index 3aa439b3ea..9214087c42 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; @@ -139,6 +142,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 @@ -431,6 +437,12 @@ 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 setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override Future setShowDataSourceBadges(bool value) async => state = state.copyWith(showDataSourceBadges: value); @override @@ -494,14 +506,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()), @@ -517,6 +544,7 @@ void main() { ProviderScope( overrides: getOverrides(), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -534,6 +562,7 @@ void main() { ProviderScope( overrides: getOverrides(), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -549,11 +578,77 @@ 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( + locale: Locale('en'), + 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( + locale: Locale('en'), + 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( + locale: Locale('en'), + 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( overrides: getOverrides(), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -589,6 +684,7 @@ void main() { ProviderScope( overrides: getOverrides(diveRecordsOverride: (ref) async => records), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), @@ -602,6 +698,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( @@ -611,6 +773,7 @@ void main() { }, ), child: const MaterialApp( + locale: Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: RecordsPage(), 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/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', + ); + }); + }); +} 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); }); 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'); 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..43b962fd0f 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,206 @@ $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('accepts comma-separated picture coordinates', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media.single['latitude'], closeTo(18.465562, 1e-6)); + expect(media.single['longitude'], closeTo(-66.084902, 1e-6)); + }); + + test('rejects NaN picture coordinates', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + // double.tryParse happily parses 'NaN', and every comparison against + // NaN is false, so a range check alone would wave it through. + final media = result.entitiesOf(ImportEntityType.media); + expect(media.single['latitude'], isNull); + expect(media.single['longitude'], isNull); + }); + + test('rejects out-of-range picture coordinates', () async { + final result = await parser.parse( + xmlBytes(''' + + + + + + + +'''), + ); + + final media = result.entitiesOf(ImportEntityType.media); + expect(media.single['latitude'], isNull); + expect(media.single['longitude'], isNull); + }); + + 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); + }); + }); } 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)); + }); + }); } 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); + }); +} 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); + }, + ); + }); }); } 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); + }, + ); +} 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 { diff --git a/test/helpers/fake_nominatim.dart b/test/helpers/fake_nominatim.dart new file mode 100644 index 0000000000..e8a5570392 --- /dev/null +++ b/test/helpers/fake_nominatim.dart @@ -0,0 +1,153 @@ +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 +/// 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, + this.statusFor, + }); + + final int statusCode; + final String body; + + /// 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 = >[]; + 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.statusForUri(uri), + _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; +} diff --git a/test/helpers/mock_providers.dart b/test/helpers/mock_providers.dart index 92e8416823..389cdac9af 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 @@ -381,6 +384,12 @@ 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 setDefaultShowEstimatedTankPressure(bool value) async => + state = state.copyWith(defaultShowEstimatedTankPressure: value); + @override Future setDefaultShowGasTimeline(bool value) async => state = state.copyWith(defaultShowGasTimeline: value); @override diff --git a/test/integration/uddf_round_trip_test.dart b/test/integration/uddf_round_trip_test.dart index d5ebe7ff4c..59f9ef0dc2 100644 --- a/test/integration/uddf_round_trip_test.dart +++ b/test/integration/uddf_round_trip_test.dart @@ -1,563 +1,568 @@ -// 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); + addTearDown(() => LocationService.throttle = NominatimThrottle()); + // 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)); + }, + ); + }); +} 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; } 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); + }, + ); + }); +}