From ea355ba217222b79eee67b729423facc26a9a65a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:16:59 -0400 Subject: [PATCH 01/35] docs: add divelogs.de sync design spec --- .../2026-07-16-divelogs-de-sync-design.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md diff --git a/docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md b/docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md new file mode 100644 index 0000000000..b1c65930be --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md @@ -0,0 +1,233 @@ +# divelogs.de Sync — Design + +Date: 2026-07-16 +Status: Approved design, pending implementation plan +Roadmap: FEATURE_ROADMAP.md section 13.2 ("Upload to divelogs.de", planned v2.0) +Contact: Rainer (divelogs.de developer, mail@divelogs.de) — offered API support and testing + +## Summary + +User-triggered, two-way, create-only sync between Submersion and divelogs.de, +modeled on the sync that divelogs.de already has with Subsurface and Diving Log. +A user connects their divelogs.de account once; from then on a sync action +compares the two logbooks by date/time and lets the user pull dives missing +locally and push dives missing remotely. Scope covers everything the divelogs +API exposes — dives, gear, certifications, and pictures — delivered in four +phases, each independently shippable. + +## The divelogs.de API + +OpenAPI spec: https://divelogs.de/api/docs/divelogs-openapi3.json +(Swagger UI at https://www.divelogs.de/api/docs/). Base URL: `https://divelogs.de/api`. + +- Auth: `POST /login` (multipart form, `user` + `pass`) returns a JWT bearer + token. No OAuth, no refresh grant. All other endpoints take + `Authorization: Bearer `. +- Dives are plain JSON (not UDDF). Mandatory fields: `date`, `time`, + `duration` (seconds), `maxdepth`. Optional: `meandepth`, `sampledata` + (array of depths, or `{d, t}` objects for depth+temperature), `samplerate`, + `tanks[]` (`o2`, `he`, `start_pressure`, `end_pressure`, `vol`, `wp`, + `dbltank`, `tankname`), `buddy` (string), `divesite`/`location` (strings), + `lat`/`lng`, `notes`, `weather`, `visibility`, `weights`, `airtemp`, + `surfacetemp`, `depthtemp`, `dc_model`, `gearitems` (array of remote gear + IDs), `boat`, `surface_interval`. +- Endpoints: `GET /divelist` (short list), `GET /dives` (all, full detail), + `GET /dives/{dive_id_list}` (batched detail), `POST /dives` (bulk create), + `POST /dive`, `GET/PUT/DELETE /dive/{id}`, `GET/POST/PUT/DELETE` for + `/gear`, `/certifications`, `GET /geartypes`, + `GET/POST /pictures/{dive_id}`, `DELETE /pictures/{picture_id}`. +- No pagination or rate limits documented. Dedup convention across existing + integrations: match dives by date+time. + +The divelogs dive model is lossier than Submersion's: one profile channel +(depth + optional temperature), buddy and site are bare strings, no deco/CCR +data, no multi-computer sources. Push is therefore a projection; pull is an +enrichment problem (strings matched to real Site/Buddy entities by the +existing import pipeline). + +## Decisions + +| Decision | Choice | +|----------|--------| +| Scope | Two-way, user-triggered sync (Rainer's model) | +| Sync semantics | Stateless, create-only. Diff by date/time each sync; never update or delete on either side. Idempotent by construction. | +| Entities | Everything: dives, gear, certifications, pictures — phased | +| Sync UX | Compare first, then a review screen with per-item toggles before commit | +| Diver scope | Account is bound to one Submersion diver at connect time (default: active diver); multiple divelogs accounts may coexist | +| Collaboration | Eric implements; Rainer advises (API behavior, test account, fixtures) | +| Architecture | Approach A: connected-account adapter + dedicated sync feature module; pull rides the universal import pipeline | + +## Phasing + +| Phase | Content | Ships alone? | +|-------|---------|--------------| +| 1 | `AccountKind.divelogs`, adapter, API client, connect flow, **pull dives** via the import wizard | Yes — covers the migration use case | +| 2 | **Push dives** + unified compare/review sync page | Yes | +| 3 | Gear + certifications, both directions | Yes | +| 4 | Pictures, both directions | Yes — independently droppable | + +Each phase is a separate PR. + +## Architecture + +### Account and authentication (Phase 1) + +- `AccountKind.divelogs` added to `lib/core/services/accounts/account_kind.dart` + (`cloudProviderType` → null; connector kind, like Adobe Lightroom). +- `DivelogsAccountAdapter` in + `lib/core/services/accounts/adapters/divelogs_account_adapter.dart`, + implementing `AccountProviderAdapter` plus a new capability interface + `LogbookSyncCapable` (named generically; divelogs is its first + implementation). Registered in `accountProviderRegistryProvider` + (`lib/core/providers/account_providers.dart`). +- Credentials: keychain blob via `AccountCredentialsStore` + (`account__credentials`) holding + `{username, password, bearerToken, tokenObtainedAt}`. The password must be + stored because JWTs expire and there is no refresh grant; the client + re-logins transparently on 401 with single-flight de-duplication (pattern: + `DropboxAuthManager._refreshInFlight`). Credentials are device-local; other + devices show `needsSignIn` until the user re-enters the password there. +- Connect flow: dialog (pattern: `dropbox_connect_dialog.dart`) with + username, password, and a diver picker defaulting to the active diver. + Validates via `/login` + `GET /user`. Creates a `ConnectedAccount` with + `accountIdentifier` = divelogs username. +- Diver binding: new nullable `diverId` column on the `connected_accounts` + table (used only by connector kinds). The table is synced and HLC-stamped, + and diver IDs are library-scoped, so the binding travels with the library. + Schema migration takes the next free version — v113 at time of writing; + re-verify the ladder when implementation starts. +- Connected Accounts page: add the `AccountKind.divelogs` icon case in + `_AccountTile` and route the tile's tap to the sync page (Phase 2) or the + import flow (Phase 1). + +### Sync engine + +New feature module `lib/features/divelogs_sync/` (`data/`, `domain/`, +`presentation/`): + +- `DivelogsApiClient` (`data/api/`) — thin typed wrapper over the REST + endpoints. No business logic. Owns auth header injection and 401 re-login. +- `DivelogsSyncPlanner` (`domain/services/`) — builds a `SyncPlan`: + 1. Fetch `GET /divelist` (cheap match keys only). + 2. Load local dive summaries for the bound diver + (`DiveRepositoryImpl.getDiveSummaries`). + 3. Match both directions with the existing `MatchScorer` + (`lib/core/matching/match_scorer.dart`) configured like `DiveMatcher` + (#494): time-gated (zero band ±15 min), depth and duration refine the + score. Remote-only → pull candidates; local-only → push candidates; + matched pairs → skipped. +- Compare uses only the short divelist; full dive detail is fetched only for + dives the user commits to pulling (`GET /dives/{id_list}`, batched). This + keeps compare cost independent of logbook size (read-amplification lesson + from #358). + +### Pull path (Phase 1) + +- `DivelogsImportMapper` (`data/mappers/`) converts divelogs JSON dives into + the existing `ImportPayload` structure, then the standard pipeline takes + over: `ImportDuplicateChecker` → review step → commit, identical to a file + import. Entry point: a "From divelogs.de" source in the unified import + wizard (an `ImportSourceAdapter` in + `lib/features/import_wizard/data/adapters/`). +- Field mapping: + - `date` + `time` → `entryTime`; `duration` → runtime; `maxdepth`, + `meandepth` → depths. API units assumed metric (open question 1). + - `sampledata` + `samplerate` → `DiveProfilePoint` list + (timestamp = index × samplerate; `{d, t}` objects carry temperature). + - `tanks[]` → `DiveTank` (`o2`/`he` → `GasMix`, pressures, `vol`, `wp`); + `dbltank` semantics: open question 4. + - `divesite`/`location` + `lat`/`lng` → `ImportEntityType.diveSites` + entity; existing site matcher (name + 100 m haversine) links or creates. + - `buddy` string → buddy entity candidate (name-only, as other importers). + - `weather`, `visibility`, `notes`, temps, `weights`, `dc_model` → + corresponding dive/condition fields. + - Provenance: `importSource = 'divelogs.de'`, `importId = `. + Pass 0 of `ImportDuplicateChecker` (exact source-key match) then makes + re-pulls instant no-ops, and the recorded remote ID leaves the door open + for future edit propagation without re-matching. + +### Push path (Phase 2) + +- `DivelogsExportMapper` projects a domain `Dive` to divelogs JSON. Lossy by + design: primary computer's profile channel only (depth + temperature), + tanks, site name + GPS, buddy names joined to one string, notes, temps, + weights, `dc_model` from the dive's computer. +- Commit via `POST /dives` bulk, chunked (~50 dives per request; open + question 5), small courtesy delay between chunks. +- Nothing is written back onto the local dive after a push (stateless model); + the next compare matches it by date/time. + +### Sync page (Phase 2) + +`presentation/pages/divelogs_sync_page.dart`: shows account status, a +Compare/Sync action, then the plan as two sections — "Pull from divelogs.de" +and "Push to divelogs.de" — all items checked by default with per-dive +toggles, one commit button with progress. Pull commits reuse the import +wizard's progress/summary machinery. All displayed values respect the active +diver's unit settings. + +### Gear and certifications (Phase 3) + +- Gear: `GET /gear` ↔ `gear` table. Match by normalized name (+ gear type + via `GET /geartypes` where mappable). Create-only both ways. On pull, the + `gearitems` ID array on remote dives resolves to dive-gear links. On push, + gear is created remotely before dives that reference it, within the same + commit (the API returns created IDs). +- Certifications: `GET/POST /certifications` ↔ Submersion certifications. + Match by (agency, level name, date). Agency/level strings map through + `CertificationLevelCatalog.levelsFor(agency, ensure:)` where recognized; + otherwise import as free-text levels. + +### Pictures (Phase 4) + +Per-dive, only for dives already matched. Pull: download into the dive's +media, dedup by content hash. Push: upload originals for user-selected dives. +Touches the media store; last and independently droppable. + +## Error handling + +- Auth: 401 mid-sync → one silent re-login; if that fails, abort with + `AccountStatus.needsSignIn` on the tile. No rollback needed — every created + entity is independent. +- Partial failure: chunked pushes stop at the failed chunk and report + "pushed N of M". Re-running sync resumes naturally: already-pushed dives + now match and drop out of the plan. Re-pulls are no-ops via Pass 0 + source-key dedup. +- Network/server errors: surfaced through the import wizard's error + presentation; no automatic retries beyond the single auth retry. +- Malformed remote data: mapper is defensive; a dive that fails mapping is + skipped and counted in the summary, never aborts the batch. +- Rate limiting: none documented; treat HTTP 429/5xx as stop-and-report. + +## Testing + +- Unit: mappers both directions (fixture round-trip asserting the lossy + projection is stable); `DivelogsSyncPlanner` diff logic (match/no-match, + same-time-different-depth, ±15 min boundary); API client over a mocked + HTTP layer (login, 401 re-login single-flight, chunking). +- Fixtures: representative JSON from Rainer (with `{d,t}` sampledata, + multi-tank, umlaut-heavy site names) under `test/fixtures/divelogs/`. +- Widget: connect dialog validation; sync review page selection behavior. +- Integration: end-to-end pull through `ImportDuplicateChecker` with an + in-memory DB, asserting site linking and second-pull idempotency. + +## Open questions for Rainer (refine, don't block, Phase 1) + +1. Units — metric everywhere in the JSON (m, bar, degrees C, kg)? Any locale + variance? +2. JWT lifetime, and is `POST /login` rate-limited? +3. `GET /divelist` response shape (not in the spec) — does it include + duration and maxdepth? +4. `dbltank` semantics — is `vol` the single-cylinder volume or the doubled + total? +5. Reasonable bulk-push chunk size, and any request-size limits? +6. Picture upload format/size constraints (Phase 4). +7. A test account (or sandbox) Submersion CI/dev can use. + +## Out of scope + +- Updating or deleting dives on either side (create-only model). +- Automatic/background sync — always user-triggered. +- Round-tripping through UDDF (the API speaks its own JSON; direct mappers + are simpler and lossless relative to what the API can carry). +- Multi-account merge semantics beyond the per-diver binding. From a7f4ff18a6d3e0be6ee7f6dccc2cb8807b90ee07 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:37:41 -0400 Subject: [PATCH 02/35] docs: add divelogs.de sync phase 1 implementation plan --- .../plans/2026-07-16-divelogs-sync-phase1.md | 1980 +++++++++++++++++ 1 file changed, 1980 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-divelogs-sync-phase1.md diff --git a/docs/superpowers/plans/2026-07-16-divelogs-sync-phase1.md b/docs/superpowers/plans/2026-07-16-divelogs-sync-phase1.md new file mode 100644 index 0000000000..94cf33da14 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-divelogs-sync-phase1.md @@ -0,0 +1,1980 @@ +# divelogs.de Sync — Phase 1 (Account + Pull Dives) 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:** Add a divelogs.de connected account and a "From divelogs.de" import source that pulls a user's entire divelogs.de logbook into Submersion through the existing universal-import pipeline. + +**Architecture:** A new `AccountKind.divelogs` connector account stores username/password/JWT in the keychain via `AccountCredentialsStore`. A REST client (`package:http`, 401-retry-once, single-flight login) fetches dives as JSON; a mapper converts them into the untyped `ImportPayload` entity maps that `UddfEntityImporter` already consumes, so dedup (`ImportDuplicateChecker`), site matching, review UI, and commit are all inherited. A thin wizard adapter subclasses `UniversalAdapter`, replacing the file-selection steps with a sign-in-and-fetch step. + +**Tech Stack:** Flutter/Dart, Riverpod (plain, no codegen), Drift, `package:http` (+ `MockClient` for tests), `flutter_secure_storage` via `FallbackSecureStorage`. + +**Spec:** `docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md` + +## Global Constraints + +- Domain units are canonical metric: depths meters, pressures bar, temps Celsius, weights kg, gas fractions percent 0–100. divelogs.de JSON is assumed metric (spec open question 1) — no conversion layer. +- All work happens in the worktree `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/divelogs-sync` on branch `worktree-divelogs-sync`. Run all commands from the worktree root. Never touch the main checkout. +- `dart format .` must produce no changes before every commit (pre-push hook enforces; never pipe `flutter analyze` through `tail`). +- No emojis in code, comments, or docs. Commit messages have NO Co-Authored-By line and NO session URL. +- New user-facing strings go into `lib/l10n/arb/app_en.arb` AND all 10 non-English locales (es, fr, de, it, nl, pt, hu, he, zh, ar), then regenerate with `flutter gen-l10n`. +- Run tests per-file (`flutter test `), never the whole suite mid-task (it is long-running). +- API base URL: `https://divelogs.de/api`. Login: `POST /login` multipart form fields `user`, `pass` → JWT. All other calls: `Authorization: Bearer `; 401 means token expired or credentials revoked. +- The remote dive id is recorded as `sourceUuid` = `divelogs:` (drives Pass-0 exact dedup and future phases). +- Drift codegen: after any `database.dart` table change run `dart run build_runner build --delete-conflicting-outputs`. + +--- + +### Task 1: `AccountKind.divelogs` enum case + exhaustive switches + +**Files:** +- Modify: `lib/core/services/accounts/account_kind.dart` +- Modify: `lib/features/settings/presentation/pages/connected_accounts_page.dart` (~line 76, `_AccountTile._icon`) +- Modify: `lib/core/services/accounts/account_startup_migration.dart` (~line 89 rekey switch, ~line 186 `_labelFor`) +- Modify: `lib/features/settings/presentation/providers/sync_providers.dart` (~line 342, `_legacyKeyFor`) +- Test: `test/core/services/accounts/account_kind_test.dart` (create) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `AccountKind.divelogs` (enum value; `cloudProviderType == null`; display label `'divelogs.de'`) used by every later task. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/accounts/account_kind_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; + +void main() { + group('AccountKind.divelogs', () { + test('has no cloud provider type (connector kind)', () { + expect(AccountKind.divelogs.cloudProviderType, isNull); + }); + + test('round-trips through name for DB persistence', () { + expect( + AccountKind.values.byName(AccountKind.divelogs.name), + AccountKind.divelogs, + ); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/core/services/accounts/account_kind_test.dart` +Expected: FAIL — compile error, `divelogs` is not a member of `AccountKind`. + +- [ ] **Step 3: Add the enum value and update every exhaustive switch** + +In `lib/core/services/accounts/account_kind.dart` add the value and the `cloudProviderType` case (`fromCloudProviderType` switches over `CloudProviderType` and needs NO change): + +```dart +enum AccountKind { + dropbox, + googledrive, + icloud, + s3, + adobeLightroom, + divelogs; + + CloudProviderType? get cloudProviderType => switch (this) { + AccountKind.dropbox => CloudProviderType.dropbox, + AccountKind.googledrive => CloudProviderType.googledrive, + AccountKind.icloud => CloudProviderType.icloud, + AccountKind.s3 => CloudProviderType.s3, + AccountKind.adobeLightroom => null, + AccountKind.divelogs => null, + }; + // fromCloudProviderType unchanged +} +``` + +Then run `flutter analyze` and fix EVERY "missing case" error it reports. Known sites (verify analyze finds no others): + +`connected_accounts_page.dart` `_icon` switch — add: +```dart + AccountKind.divelogs => Icons.travel_explore_outlined, +``` + +`account_startup_migration.dart` `_labelFor` — add: +```dart + AccountKind.divelogs => 'divelogs.de', +``` +and in the rekey switch (~line 89), add `AccountKind.divelogs` to the group of kinds that have no legacy key to migrate (same treatment as `adobeLightroom`). + +`sync_providers.dart` `_legacyKeyFor` — add a case returning `null` (non-sync connector kind). + +- [ ] **Step 4: Run test and analyze to verify they pass** + +Run: `flutter test test/core/services/accounts/account_kind_test.dart && flutter analyze` +Expected: test PASS; analyze reports no errors. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: add AccountKind.divelogs connector kind" +``` + +--- + +### Task 2: `diverId` binding column on `connected_accounts` + +**Files:** +- Modify: `lib/core/database/database.dart` (table ~line 1104, `currentSchemaVersion` ~line 2208, `_assertConnectedAccountsSchema` ~line 2354, onUpgrade ladder ~line 5538, beforeOpen backstop ~line 5547) +- Modify: `lib/core/services/accounts/connected_account.dart` +- Modify: `lib/core/data/repositories/connected_accounts_repository.dart` +- Test: extend `test/core/data/repositories/connected_accounts_repository_test.dart` if it exists; otherwise extend `test/core/services/accounts/account_provider_registry_test.dart`'s fixture builders and add repository assertions in a new `test/core/data/repositories/connected_accounts_diver_binding_test.dart` following the DB-test setup used by the existing connected-accounts tests (locate with `grep -rl "ConnectedAccountsRepository" test/`). + +**Interfaces:** +- Consumes: Task 1's enum value (tests may use it). +- Produces: `ConnectedAccount.diverId` (`String?` field + constructor param + `copyWith`); `ConnectedAccountsRepository.create({required AccountKind kind, required String label, String? accountIdentifier, String? id, String? diverId})`. + +**Schema version:** the ladder moves fast in this repo. Before writing the migration, check the CURRENT `currentSchemaVersion` in this worktree's `database.dart` AND versions claimed by open PRs (`gh pr list --state open --json number,title` + memory `schema-version-ladder`). At plan-writing time the worktree is at 112 with v113/v114 claimed by open PRs #600 and #601, so use **115**. Adjust if the ladder has moved; use one consistent number everywhere below. + +- [ ] **Step 1: Write the failing test** + +In the located repository test file add: + +```dart +test('create persists and round-trips diverId', () async { + final repo = ConnectedAccountsRepository(); + final account = await repo.create( + kind: AccountKind.divelogs, + label: 'divelogs.de', + accountIdentifier: 'rainer', + diverId: 'diver-1', + ); + expect(account.diverId, 'diver-1'); + final loaded = await repo.getById(account.id); + expect(loaded?.diverId, 'diver-1'); +}); +``` + +(Match the file's existing setup — in-memory `DatabaseService` initialization — exactly as its sibling tests do.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test ` +Expected: FAIL — `create` has no `diverId` parameter. + +- [ ] **Step 3: Add the column, migration, and self-heal backstop** + +In `database.dart`: + +1. Table gains a nullable column: +```dart +class ConnectedAccounts extends Table { + // ... existing columns ... + TextColumn get diverId => text().nullable()(); +``` + +2. `static const int currentSchemaVersion = 115;` (see Schema version note above). + +3. New idempotent helper next to `_assertEquipmentThicknessColumn` (copy its shape exactly): +```dart +Future _assertConnectedAccountsDiverIdColumn() async { + final cols = await customSelect( + "PRAGMA table_info('connected_accounts')", + ).get(); + final hasDiverId = cols.any((c) => c.read('name') == 'diver_id'); + if (cols.isNotEmpty && !hasDiverId) { + await customStatement( + 'ALTER TABLE connected_accounts ADD COLUMN diver_id TEXT', + ); + } +} +``` + +4. In onUpgrade, after the `if (from < 112)` block: +```dart +if (from < 115) { + await _assertConnectedAccountsDiverIdColumn(); +} +if (from < 115) await reportProgress(); +``` + +5. In the `beforeOpen` backstop list, add `await _assertConnectedAccountsDiverIdColumn();` after the existing `_assertEquipmentThicknessColumn()` call. + +6. Add `diver_id TEXT` to the `CREATE TABLE IF NOT EXISTS connected_accounts (...)` DDL inside `_assertConnectedAccountsSchema` so fresh self-healed tables include it. + +Run codegen: `dart run build_runner build --delete-conflicting-outputs` + +In `connected_account.dart` add the field, constructor param, and carry it through `copyWith`: +```dart +final String? diverId; +// constructor: this.diverId, +// copyWith: diverId stays fixed (not a copyWith param — bindings don't change after creation) +``` +(In `copyWith`, pass `diverId: diverId` through to the new instance.) + +In `connected_accounts_repository.dart`: `create()` gains `String? diverId` and writes `diverId: Value(diverId)` into the companion; `_toDomain` maps `diverId: row.diverId`. + +Note: sync serialization needs NO manual change — `sync_data_serializer.dart` uses the drift-generated `ConnectedAccount.fromJson(...).toCompanion(false)` generically (verified at lines 1900 and 2321), so the new column rides along after codegen. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/core/services/accounts/ && flutter analyze` +Expected: PASS, no analyze errors. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: bind connected accounts to a diver via diver_id column (v115)" +``` + +--- + +### Task 3: Divelogs credentials model + auth manager + +**Files:** +- Create: `lib/core/services/divelogs/divelogs_credentials.dart` +- Create: `lib/core/services/divelogs/divelogs_auth_manager.dart` +- Test: `test/core/services/divelogs/divelogs_auth_manager_test.dart` + +**Interfaces:** +- Consumes: `AccountCredentialsStore` (`read/write/delete(accountId)`), `package:http`. +- Produces: + - `DivelogsCredentials({required String username, required String password, String? bearerToken})` with `toJsonString()` / `static DivelogsCredentials? fromJsonString(String?)` / `copyWith({String? bearerToken})`. + - `DivelogsAuthManager({required AccountCredentialsStore credentials, required String accountId, http.Client? httpClient})` with `Future getToken()`, `void invalidateToken()`, `Future disconnect()`. + - `static Future DivelogsAuthManager.login({required String username, required String password, http.Client? httpClient})` — unauthenticated; used by the connect step to validate before an account exists. + - `class DivelogsAuthException implements Exception { final String message; }` + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/divelogs/divelogs_auth_manager_test.dart` (uses the existing `InMemoryKeychain` fake — import path pattern per `test/core/services/accounts/account_credentials_store_test.dart`, e.g. `../../support/fake_keychain_storage.dart` adjusted for depth): + +```dart +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/divelogs/divelogs_auth_manager.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; + +import '../../../support/fake_keychain_storage.dart'; + +void main() { + late InMemoryKeychain keychain; + late AccountCredentialsStore store; + + setUp(() { + keychain = InMemoryKeychain(); + store = AccountCredentialsStore( + storage: keychain as FlutterSecureStorage, + ); + }); + + MockClient loginOk({String token = 'jwt-1', List? log}) => + MockClient((req) async { + log?.add(req); + expect(req.url.path, '/api/login'); + return http.Response(jsonEncode({'bearer_token': token}), 200); + }); + + Future seedCreds({String? token}) => store.write( + 'acc-1', + DivelogsCredentials( + username: 'eric', + password: 'secret', + bearerToken: token, + ).toJsonString(), + ); + + test('login returns token from bearer_token field', () async { + final token = await DivelogsAuthManager.login( + username: 'eric', + password: 'secret', + httpClient: loginOk(), + ); + expect(token, 'jwt-1'); + }); + + test('login throws DivelogsAuthException on 401', () async { + final client = MockClient((_) async => http.Response('', 401)); + expect( + () => DivelogsAuthManager.login( + username: 'eric', + password: 'bad', + httpClient: client, + ), + throwsA(isA()), + ); + }); + + test('getToken uses persisted token without hitting network', () async { + await seedCreds(token: 'persisted'); + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: MockClient((_) async => fail('no network call expected')), + ); + expect(await manager.getToken(), 'persisted'); + }); + + test('getToken logs in when no token persisted and persists result', + () async { + await seedCreds(); + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: loginOk(token: 'fresh'), + ); + expect(await manager.getToken(), 'fresh'); + final blob = DivelogsCredentials.fromJsonString(await store.read('acc-1')); + expect(blob?.bearerToken, 'fresh'); + }); + + test('getToken is single-flight for concurrent callers', () async { + await seedCreds(); + final log = []; + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: loginOk(log: log), + ); + final results = await Future.wait([ + manager.getToken(), + manager.getToken(), + manager.getToken(), + ]); + expect(results.toSet(), {'jwt-1'}); + expect(log.length, 1); + }); + + test('invalidateToken forces a fresh login ignoring persisted token', + () async { + await seedCreds(token: 'stale'); + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: loginOk(token: 'renewed'), + ); + expect(await manager.getToken(), 'stale'); + manager.invalidateToken(); + expect(await manager.getToken(), 'renewed'); + }); + + test('disconnect deletes the credentials blob', () async { + await seedCreds(token: 't'); + final manager = DivelogsAuthManager(credentials: store, accountId: 'acc-1'); + await manager.disconnect(); + expect(await store.read('acc-1'), isNull); + }); + + test('getToken throws when not signed in', () async { + final manager = DivelogsAuthManager(credentials: store, accountId: 'acc-1'); + expect(() => manager.getToken(), throwsA(isA())); + }); +} +``` + +(If `InMemoryKeychain` doesn't implement `FlutterSecureStorage` directly, mirror the constructor usage from `account_credentials_store_test.dart` verbatim instead of casting.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/core/services/divelogs/divelogs_auth_manager_test.dart` +Expected: FAIL — files under `lib/core/services/divelogs/` don't exist. + +- [ ] **Step 3: Implement the model and manager** + +`lib/core/services/divelogs/divelogs_credentials.dart`: + +```dart +import 'dart:convert'; + +/// Keychain payload for a divelogs.de account. +/// +/// The password is stored because divelogs.de issues expiring JWTs with no +/// refresh grant; re-login is the only renewal path. +class DivelogsCredentials { + final String username; + final String password; + final String? bearerToken; + + const DivelogsCredentials({ + required this.username, + required this.password, + this.bearerToken, + }); + + DivelogsCredentials copyWith({String? bearerToken}) => DivelogsCredentials( + username: username, + password: password, + bearerToken: bearerToken ?? this.bearerToken, + ); + + String toJsonString() => jsonEncode({ + 'username': username, + 'password': password, + if (bearerToken != null) 'bearerToken': bearerToken, + }); + + static DivelogsCredentials? fromJsonString(String? raw) { + if (raw == null || raw.isEmpty) return null; + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final username = decoded['username'] as String?; + final password = decoded['password'] as String?; + if (username == null || password == null) return null; + return DivelogsCredentials( + username: username, + password: password, + bearerToken: decoded['bearerToken'] as String?, + ); + } +} +``` + +`lib/core/services/divelogs/divelogs_auth_manager.dart`: + +```dart +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; + +class DivelogsAuthException implements Exception { + final String message; + const DivelogsAuthException(this.message); + + @override + String toString() => 'DivelogsAuthException: $message'; +} + +/// Owns the divelogs.de JWT lifecycle for one connected account. +/// +/// divelogs.de has no OAuth: POST /login with username/password returns a +/// JWT. Renewal is 401-driven — the API client calls [invalidateToken] and +/// retries once, which triggers a fresh login here. +class DivelogsAuthManager { + DivelogsAuthManager({ + required AccountCredentialsStore credentials, + required this.accountId, + http.Client? httpClient, + }) : _credentials = credentials, + _http = httpClient ?? http.Client(); + + static final Uri loginUri = Uri.parse('https://divelogs.de/api/login'); + + final AccountCredentialsStore _credentials; + final String accountId; + final http.Client _http; + + String? _cachedToken; + bool _forceRelogin = false; + Future? _loginInFlight; + + /// Unauthenticated login. Used by the connect flow to validate credentials + /// before a ConnectedAccount exists, and internally for renewal. + static Future login({ + required String username, + required String password, + http.Client? httpClient, + }) async { + final client = httpClient ?? http.Client(); + final request = http.MultipartRequest('POST', loginUri) + ..fields['user'] = username + ..fields['pass'] = password; + final http.Response response; + try { + response = await http.Response.fromStream(await client.send(request)); + } on Exception { + throw const DivelogsAuthException('Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + throw const DivelogsAuthException( + 'divelogs.de rejected the username or password.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsAuthException( + 'divelogs.de login failed (HTTP ${response.statusCode}).', + ); + } + final token = _extractToken(response.body); + if (token == null) { + throw const DivelogsAuthException( + 'divelogs.de login response did not contain a token.', + ); + } + return token; + } + + static String? _extractToken(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + for (final key in const ['bearer_token', 'token', 'access_token']) { + final value = decoded[key]; + if (value is String && value.isNotEmpty) return value; + } + } + } on FormatException { + // fall through + } + return null; + } + + Future getToken() { + final cached = _cachedToken; + if (cached != null) return Future.value(cached); + return _loginInFlight ??= _resolveToken().whenComplete(() { + _loginInFlight = null; + }); + } + + Future _resolveToken() async { + final stored = DivelogsCredentials.fromJsonString( + await _credentials.read(accountId), + ); + if (stored == null) { + throw const DivelogsAuthException('Not signed in to divelogs.de.'); + } + final persisted = stored.bearerToken; + if (!_forceRelogin && persisted != null && persisted.isNotEmpty) { + _cachedToken = persisted; + return persisted; + } + final token = await login( + username: stored.username, + password: stored.password, + httpClient: _http, + ); + _forceRelogin = false; + _cachedToken = token; + await _credentials.write( + accountId, + stored.copyWith(bearerToken: token).toJsonString(), + ); + return token; + } + + /// Called by the API client when a request came back 401. + void invalidateToken() { + _cachedToken = null; + _forceRelogin = true; + } + + Future disconnect() async { + _cachedToken = null; + _forceRelogin = false; + await _credentials.delete(accountId); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/core/services/divelogs/divelogs_auth_manager_test.dart` +Expected: PASS (all 8 tests). + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib/core/services/divelogs test/core/services/divelogs +git commit -m "feat: add divelogs.de credentials model and JWT auth manager" +``` + +--- + +### Task 4: divelogs.de API client + JSON models + +**Files:** +- Create: `lib/core/services/divelogs/divelogs_models.dart` +- Create: `lib/core/services/divelogs/divelogs_api_client.dart` +- Test: `test/core/services/divelogs/divelogs_models_test.dart` +- Test: `test/core/services/divelogs/divelogs_api_client_test.dart` + +**Interfaces:** +- Consumes: Task 3's `DivelogsAuthManager` shape only via callbacks (client stays auth-agnostic, mirroring `DropboxApiClient`). +- Produces: + - `class DivelogsSample { final double depth; final double? temperature; }` + - `class DivelogsTank { final double? o2, he, startPressure, endPressure, volume, workingPressure; final bool dbltank; final String? name; }` + - `class DivelogsDive { final String? id; final DateTime dateTime; final int durationSeconds; final double maxDepth; final double? meanDepth, latitude, longitude, airTemp, depthTemp, surfaceTemp, weightsKg; final int? sampleRateSeconds, surfaceIntervalSeconds; final List samples; final List tanks; final String? buddy, siteName, location, notes, weather, visibility, boat, dcModel; factory DivelogsDive.fromJson(Map) }` — throws `FormatException` when mandatory `date`/`time`/`duration`/`maxdepth` are missing/unparseable. + - `class DivelogsDivesResult { final List dives; final int skippedCount; }` + - `class DivelogsApiException implements Exception { final int statusCode; final String message; }` + - `DivelogsApiClient({required Future Function() getBearerToken, required void Function() onTokenRejected, http.Client? httpClient, Uri? baseUri})` with `Future> getUser()` and `Future getAllDives()`. + +- [ ] **Step 1: Write failing model tests** + +Create `test/core/services/divelogs/divelogs_models_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; + +void main() { + Map minimal() => { + 'id': 4711, + 'date': '2022-09-03', + 'time': '14:42:00', + 'duration': 2808, + 'maxdepth': 12, + }; + + test('parses mandatory fields', () { + final dive = DivelogsDive.fromJson(minimal()); + expect(dive.id, '4711'); + expect(dive.dateTime, DateTime(2022, 9, 3, 14, 42)); + expect(dive.durationSeconds, 2808); + expect(dive.maxDepth, 12.0); + expect(dive.samples, isEmpty); + expect(dive.tanks, isEmpty); + }); + + test('throws FormatException when a mandatory field is missing', () { + final json = minimal()..remove('maxdepth'); + expect(() => DivelogsDive.fromJson(json), throwsFormatException); + }); + + test('parses mixed sampledata (bare depths and {d,t} objects)', () { + final dive = DivelogsDive.fromJson({ + ...minimal(), + 'samplerate': 10, + 'sampledata': [ + {'d': 1, 't': 13}, + 10, + {'d': 17, 't': 12}, + 0, + ], + }); + expect(dive.sampleRateSeconds, 10); + expect(dive.samples, hasLength(4)); + expect(dive.samples[0].depth, 1.0); + expect(dive.samples[0].temperature, 13.0); + expect(dive.samples[1].depth, 10.0); + expect(dive.samples[1].temperature, isNull); + }); + + test('parses tanks', () { + final dive = DivelogsDive.fromJson({ + ...minimal(), + 'tanks': [ + { + 'o2': 28, + 'he': 0, + 'start_pressure': 214.56, + 'end_pressure': 103, + 'vol': 12, + 'wp': 200, + 'dbltank': false, + 'tankname': 'Main', + }, + ], + }); + expect(dive.tanks, hasLength(1)); + final tank = dive.tanks.single; + expect(tank.o2, 28.0); + expect(tank.startPressure, 214.56); + expect(tank.endPressure, 103.0); + expect(tank.volume, 12.0); + expect(tank.workingPressure, 200.0); + expect(tank.name, 'Main'); + }); + + test('parses optional metadata fields', () { + final dive = DivelogsDive.fromJson({ + ...minimal(), + 'meandepth': 7.9, + 'buddy': 'Buddy', + 'divesite': 'Shinenead', + 'location': 'Aegypten, Rotes Meer', + 'lat': 24.669683, + 'lng': 35.125225, + 'notes': 'nice dive', + 'weather': 'sunny', + 'visibility': 'good', + 'airtemp': 28, + 'depthtemp': 21, + 'surfacetemp': 26, + 'weights': 4, + 'surface_interval': 3600, + 'dc_model': 'Suunto D6', + }); + expect(dive.meanDepth, 7.9); + expect(dive.buddy, 'Buddy'); + expect(dive.siteName, 'Shinenead'); + expect(dive.latitude, closeTo(24.669683, 1e-9)); + expect(dive.depthTemp, 21.0); + expect(dive.weightsKg, 4.0); + expect(dive.surfaceIntervalSeconds, 3600); + expect(dive.dcModel, 'Suunto D6'); + }); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `flutter test test/core/services/divelogs/divelogs_models_test.dart` +Expected: FAIL — `divelogs_models.dart` doesn't exist. + +- [ ] **Step 3: Implement `divelogs_models.dart`** + +```dart +/// Typed views over the divelogs.de REST API JSON. +/// +/// Field names and semantics follow the OpenAPI spec at +/// https://divelogs.de/api/docs/divelogs-openapi3.json. All values are +/// metric (meters, bar, Celsius, kg). +library; + +double? _asDouble(Object? v) => switch (v) { + num n => n.toDouble(), + String s => double.tryParse(s), + _ => null, +}; + +int? _asInt(Object? v) => switch (v) { + num n => n.toInt(), + String s => int.tryParse(s), + _ => null, +}; + +String? _asNonEmptyString(Object? v) { + if (v is! String) return null; + final trimmed = v.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +class DivelogsSample { + final double depth; + final double? temperature; + + const DivelogsSample({required this.depth, this.temperature}); +} + +class DivelogsTank { + final double? o2; + final double? he; + final double? startPressure; + final double? endPressure; + final double? volume; + final double? workingPressure; + final bool dbltank; + final String? name; + + const DivelogsTank({ + this.o2, + this.he, + this.startPressure, + this.endPressure, + this.volume, + this.workingPressure, + this.dbltank = false, + this.name, + }); + + factory DivelogsTank.fromJson(Map json) => DivelogsTank( + o2: _asDouble(json['o2']), + he: _asDouble(json['he']), + startPressure: _asDouble(json['start_pressure']), + endPressure: _asDouble(json['end_pressure']), + volume: _asDouble(json['vol']), + workingPressure: _asDouble(json['wp']), + dbltank: json['dbltank'] == true, + name: _asNonEmptyString(json['tankname']) ?? _asNonEmptyString(json['tank']), + ); +} + +class DivelogsDive { + final String? id; + final DateTime dateTime; + final int durationSeconds; + final double maxDepth; + final double? meanDepth; + final int? sampleRateSeconds; + final List samples; + final List tanks; + final String? buddy; + final String? siteName; + final String? location; + final String? notes; + final String? weather; + final String? visibility; + final String? boat; + final String? dcModel; + final double? latitude; + final double? longitude; + final double? airTemp; + final double? depthTemp; + final double? surfaceTemp; + final double? weightsKg; + final int? surfaceIntervalSeconds; + + const DivelogsDive({ + this.id, + required this.dateTime, + required this.durationSeconds, + required this.maxDepth, + this.meanDepth, + this.sampleRateSeconds, + this.samples = const [], + this.tanks = const [], + this.buddy, + this.siteName, + this.location, + this.notes, + this.weather, + this.visibility, + this.boat, + this.dcModel, + this.latitude, + this.longitude, + this.airTemp, + this.depthTemp, + this.surfaceTemp, + this.weightsKg, + this.surfaceIntervalSeconds, + }); + + factory DivelogsDive.fromJson(Map json) { + final date = _asNonEmptyString(json['date']); + final time = _asNonEmptyString(json['time']) ?? '00:00:00'; + final duration = _asInt(json['duration']); + final maxDepth = _asDouble(json['maxdepth']); + if (date == null || duration == null || maxDepth == null) { + throw FormatException('divelogs dive missing mandatory fields', json); + } + final DateTime dateTime; + try { + dateTime = DateTime.parse('$date $time'); + } on FormatException { + throw FormatException('divelogs dive has unparseable date/time', json); + } + + final samples = []; + final sampleData = json['sampledata']; + if (sampleData is List) { + for (final entry in sampleData) { + if (entry is num) { + samples.add(DivelogsSample(depth: entry.toDouble())); + } else if (entry is Map) { + final d = _asDouble(entry['d']); + if (d != null) { + samples.add( + DivelogsSample(depth: d, temperature: _asDouble(entry['t'])), + ); + } + } + } + } + + final tanks = []; + final tanksJson = json['tanks']; + if (tanksJson is List) { + for (final t in tanksJson) { + if (t is Map) { + tanks.add(DivelogsTank.fromJson(Map.from(t))); + } + } + } + + final rawId = json['id'] ?? json['dive_id']; + return DivelogsDive( + id: rawId == null ? null : '$rawId', + dateTime: dateTime, + durationSeconds: duration, + maxDepth: maxDepth, + meanDepth: _asDouble(json['meandepth']), + sampleRateSeconds: _asInt(json['samplerate']), + samples: samples, + tanks: tanks, + buddy: _asNonEmptyString(json['buddy']), + siteName: _asNonEmptyString(json['divesite']), + location: _asNonEmptyString(json['location']), + notes: _asNonEmptyString(json['notes']), + weather: _asNonEmptyString(json['weather']), + visibility: _asNonEmptyString(json['visibility']), + boat: _asNonEmptyString(json['boat']), + dcModel: _asNonEmptyString(json['dc_model']), + latitude: _asDouble(json['lat']), + longitude: _asDouble(json['lng']), + airTemp: _asDouble(json['airtemp']), + depthTemp: _asDouble(json['depthtemp']), + surfaceTemp: _asDouble(json['surfacetemp']), + weightsKg: _asDouble(json['weights']), + surfaceIntervalSeconds: _asInt(json['surface_interval']), + ); + } +} + +class DivelogsDivesResult { + final List dives; + final int skippedCount; + + const DivelogsDivesResult({required this.dives, this.skippedCount = 0}); +} +``` + +Note: `airtemp`/`weights` examples in the spec show `0` for "not set" — mapping of zero-vs-null is handled in the mapper (Task 5), not here; the model reports what the API sent. + +- [ ] **Step 4: Run model tests** + +Run: `flutter test test/core/services/divelogs/divelogs_models_test.dart` +Expected: PASS. + +- [ ] **Step 5: Write failing API client tests** + +Create `test/core/services/divelogs/divelogs_api_client_test.dart`: + +```dart +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/services/divelogs/divelogs_api_client.dart'; + +void main() { + Map diveJson(int id) => { + 'id': id, + 'date': '2022-09-03', + 'time': '14:42:00', + 'duration': 2808, + 'maxdepth': 12, + }; + + DivelogsApiClient client( + Future Function(http.Request) handler, { + void Function()? onRejected, + List? tokens, + }) { + final queue = List.from(tokens ?? ['t1']); + return DivelogsApiClient( + getBearerToken: () async => + queue.length > 1 ? queue.removeAt(0) : queue.first, + onTokenRejected: onRejected ?? () {}, + httpClient: MockClient(handler), + ); + } + + test('getAllDives sends bearer header and parses array body', () async { + late http.Request captured; + final api = client((req) async { + captured = req; + return http.Response(jsonEncode([diveJson(1), diveJson(2)]), 200); + }); + final result = await api.getAllDives(); + expect(captured.url.toString(), 'https://divelogs.de/api/dives'); + expect(captured.headers['Authorization'], 'Bearer t1'); + expect(result.dives, hasLength(2)); + expect(result.skippedCount, 0); + }); + + test('getAllDives tolerates object body with dives key', () async { + final api = client( + (req) async => http.Response( + jsonEncode({'dives': [diveJson(1)]}), + 200, + ), + ); + final result = await api.getAllDives(); + expect(result.dives, hasLength(1)); + }); + + test('getAllDives skips malformed dives and counts them', () async { + final api = client( + (req) async => http.Response( + jsonEncode([diveJson(1), {'date': '2022-01-01'}]), + 200, + ), + ); + final result = await api.getAllDives(); + expect(result.dives, hasLength(1)); + expect(result.skippedCount, 1); + }); + + test('401 invalidates token and retries exactly once', () async { + var rejections = 0; + var calls = 0; + final api = client( + (req) async { + calls++; + if (req.headers['Authorization'] == 'Bearer t1') { + return http.Response('', 401); + } + return http.Response(jsonEncode([diveJson(1)]), 200); + }, + onRejected: () => rejections++, + tokens: ['t1', 't2'], + ); + final result = await api.getAllDives(); + expect(result.dives, hasLength(1)); + expect(rejections, 1); + expect(calls, 2); + }); + + test('second 401 throws DivelogsApiException', () async { + final api = client((req) async => http.Response('', 401)); + expect( + () => api.getAllDives(), + throwsA( + isA().having((e) => e.statusCode, 'status', 401), + ), + ); + }); + + test('getUser returns decoded map', () async { + final api = client( + (req) async => http.Response(jsonEncode({'username': 'eric'}), 200), + ); + final user = await api.getUser(); + expect(user['username'], 'eric'); + }); +} +``` + +- [ ] **Step 6: Run to verify failure, then implement `divelogs_api_client.dart`** + +Run: `flutter test test/core/services/divelogs/divelogs_api_client_test.dart` — expect FAIL (file missing). Then: + +```dart +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; + +class DivelogsApiException implements Exception { + final int statusCode; + final String message; + const DivelogsApiException(this.statusCode, this.message); + + @override + String toString() => 'DivelogsApiException($statusCode): $message'; +} + +/// Thin typed wrapper over the divelogs.de REST API. +/// +/// Auth is delegated to callbacks (mirrors DropboxApiClient): on 401 the +/// client calls [onTokenRejected] (which invalidates the manager's token) +/// and retries exactly once with a freshly resolved token. +class DivelogsApiClient { + DivelogsApiClient({ + required Future Function() getBearerToken, + required void Function() onTokenRejected, + http.Client? httpClient, + Uri? baseUri, + }) : _getBearerToken = getBearerToken, + _onTokenRejected = onTokenRejected, + _http = httpClient ?? http.Client(), + _baseUri = baseUri ?? Uri.parse('https://divelogs.de/api'); + + final Future Function() _getBearerToken; + final void Function() _onTokenRejected; + final http.Client _http; + final Uri _baseUri; + + Future> getUser() async { + final response = await _get('/user'); + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const DivelogsApiException(0, 'Unexpected /user response'); + } + return Map.from(decoded); + } + + Future getAllDives() async { + final response = await _get('/dives'); + final decoded = jsonDecode(response.body); + final List rawDives; + if (decoded is List) { + rawDives = decoded; + } else if (decoded is Map && decoded['dives'] is List) { + rawDives = decoded['dives'] as List; + } else { + throw const DivelogsApiException(0, 'Unexpected /dives response'); + } + final dives = []; + var skipped = 0; + for (final raw in rawDives) { + if (raw is! Map) { + skipped++; + continue; + } + try { + dives.add(DivelogsDive.fromJson(Map.from(raw))); + } on FormatException { + skipped++; + } + } + return DivelogsDivesResult(dives: dives, skippedCount: skipped); + } + + Future _get(String path) async { + var authRetried = false; + while (true) { + final token = await _getBearerToken(); + final http.Response response; + try { + response = await _http.get( + _baseUri.replace(path: '${_baseUri.path}$path'), + headers: {'Authorization': 'Bearer $token'}, + ); + } on Exception { + throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + _onTokenRejected(); + if (!authRetried) { + authRetried = true; + continue; + } + throw const DivelogsApiException( + 401, + 'divelogs.de sign-in expired. Sign in again in Settings.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsApiException( + response.statusCode, + 'divelogs.de API error ${response.statusCode}', + ); + } + return response; + } + } +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `flutter test test/core/services/divelogs/` +Expected: PASS (models + auth manager + api client). + +- [ ] **Step 8: Commit** + +```bash +dart format . +git add -A lib/core/services/divelogs test/core/services/divelogs +git commit -m "feat: add divelogs.de REST API client with 401-retry and JSON models" +``` + +--- + +### Task 5: `LogbookSyncCapable` + `DivelogsAccountAdapter` + registry + +**Files:** +- Modify: `lib/core/services/accounts/account_provider_adapter.dart` +- Create: `lib/core/services/accounts/adapters/divelogs_account_adapter.dart` +- Modify: `lib/core/providers/account_providers.dart` +- Test: `test/core/services/accounts/adapters/divelogs_account_adapter_test.dart` + +**Interfaces:** +- Consumes: Task 3 (`DivelogsAuthManager`, `DivelogsCredentials`), Task 1 (`AccountKind.divelogs`), `AccountCredentialsStore`. +- Produces: `abstract interface class LogbookSyncCapable {}` (marker; Phase 2 will add members); `DivelogsAccountAdapter({required AccountCredentialsStore credentials, http.Client? httpClient})` with `DivelogsAuthManager authManagerFor(ConnectedAccount account)`. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/accounts/adapters/divelogs_account_adapter_test.dart`, modeled line-for-line on `dropbox_account_adapter_test.dart`'s setup (InMemoryKeychain + inline `ConnectedAccount` fixtures with `DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)`): + +```dart +// imports per dropbox_account_adapter_test.dart, plus: +// import 'package:submersion/core/services/accounts/adapters/divelogs_account_adapter.dart'; +// import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; + +void main() { + late InMemoryKeychain keychain; + late AccountCredentialsStore store; + late DivelogsAccountAdapter adapter; + + ConnectedAccount account(String id) => ConnectedAccount( + id: id, + kind: AccountKind.divelogs, + label: 'divelogs.de', + accountIdentifier: 'eric', + createdAt: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + updatedAt: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ); + + setUp(() { + keychain = InMemoryKeychain(); + store = AccountCredentialsStore(storage: keychain); + adapter = DivelogsAccountAdapter(credentials: store); + }); + + test('kind is divelogs and adapter is LogbookSyncCapable', () { + expect(adapter.kind, AccountKind.divelogs); + expect(adapter, isA()); + }); + + test('status is needsSignIn without credentials, signedIn with', () async { + expect(await adapter.status(account('a1')), AccountStatus.needsSignIn); + await store.write( + 'a1', + const DivelogsCredentials(username: 'e', password: 'p').toJsonString(), + ); + expect(await adapter.status(account('a1')), AccountStatus.signedIn); + }); + + test('disconnect deletes only this account credentials', () async { + await store.write( + 'a1', + const DivelogsCredentials(username: 'e', password: 'p').toJsonString(), + ); + await store.write( + 'a2', + const DivelogsCredentials(username: 'f', password: 'q').toJsonString(), + ); + await adapter.disconnect(account('a1')); + expect(await store.read('a1'), isNull); + expect(await store.read('a2'), isNotNull); + }); + + test('authManagerFor caches one manager per account id', () { + final m1 = adapter.authManagerFor(account('a1')); + expect(identical(m1, adapter.authManagerFor(account('a1'))), isTrue); + expect(identical(m1, adapter.authManagerFor(account('a2'))), isFalse); + }); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `flutter test test/core/services/accounts/adapters/divelogs_account_adapter_test.dart` +Expected: FAIL — adapter file missing. + +- [ ] **Step 3: Implement** + +In `account_provider_adapter.dart`, append: + +```dart +/// Marker: the account syncs with a third-party logbook service +/// (divelogs.de now). Phase 2 adds sync-plan members. +abstract interface class LogbookSyncCapable {} +``` + +Create `lib/core/services/accounts/adapters/divelogs_account_adapter.dart`: + +```dart +import 'package:http/http.dart' as http; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; +import 'package:submersion/core/services/accounts/account_provider_adapter.dart'; +import 'package:submersion/core/services/accounts/connected_account.dart' + as domain; +import 'package:submersion/core/services/divelogs/divelogs_auth_manager.dart'; + +class DivelogsAccountAdapter extends AccountProviderAdapter + implements LogbookSyncCapable { + DivelogsAccountAdapter({ + required AccountCredentialsStore credentials, + http.Client? httpClient, + }) : _credentials = credentials, + _httpClient = httpClient; + + final AccountCredentialsStore _credentials; + final http.Client? _httpClient; + final Map _managers = {}; + + @override + AccountKind get kind => AccountKind.divelogs; + + DivelogsAuthManager authManagerFor(domain.ConnectedAccount account) => + _managers.putIfAbsent( + account.id, + () => DivelogsAuthManager( + credentials: _credentials, + accountId: account.id, + httpClient: _httpClient, + ), + ); + + @override + Future status(domain.ConnectedAccount account) async { + final blob = await _credentials.read(account.id); + return (blob == null || blob.isEmpty) + ? AccountStatus.needsSignIn + : AccountStatus.signedIn; + } + + @override + Future disconnect(domain.ConnectedAccount account) async { + await authManagerFor(account).disconnect(); + _managers.remove(account.id); + } +} +``` + +Register in `account_providers.dart` registry list: + +```dart + DivelogsAccountAdapter( + credentials: ref.watch(accountCredentialsStoreProvider), + ), +``` + +- [ ] **Step 4: Run tests** + +Run: `flutter test test/core/services/accounts/ test/core/providers/account_providers_test.dart && flutter analyze` +Expected: PASS, no analyze errors. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: add divelogs.de account adapter with LogbookSyncCapable marker" +``` + +--- + +### Task 6: `DivelogsDiveMapper` — API models to ImportPayload entity maps + +**Files:** +- Create: `lib/features/universal_import/data/services/divelogs_dive_mapper.dart` +- Test: `test/features/universal_import/data/services/divelogs_dive_mapper_test.dart` + +**Interfaces:** +- Consumes: Task 4's `DivelogsDive`/`DivelogsTank`/`DivelogsSample`; `GasMix` from `package:submersion/features/dive_log/domain/entities/dive.dart`. +- Produces: `class DivelogsDiveMapper { const DivelogsDiveMapper(); Map mapDive(DivelogsDive dive); Map? mapSite(DivelogsDive dive); static String siteKey(String name); }` + +The output keys MUST match what `UddfEntityImporter` reads (verified against `uddf_entity_importer.dart` `_importDives`/`_buildTanks`): `dateTime` (DateTime), `runtime` (Duration), `maxDepth`/`avgDepth`/`waterTemp`/`airTemp` (num), `buddy` (String), `buddyRefs` (List), `notes` (String), `weightUsed` (double, importer appends "Weight used: X kg" to notes), `latitude`/`longitude` (double, becomes `entryLocation`), `diveComputerModel` (String), `surfaceInterval` (Duration), `sourceUuid` (String, feeds `DiveDataSources.source_uuid` and Pass-0 dedup), `site` (`{'uddfId': ..., 'name': ...}` — `uddfId` links to a sites-payload entity), `siteName` (String), `tanks` (list of maps with `gasMix` (GasMix), `startPressure`/`endPressure`/`workingPressure` (num), `volume` (**double**, importer casts `as double?` — always `.toDouble()`), `name`), `profile` (list of `{'timestamp': int seconds, 'depth': double, 'temperature': double?}`). Site payload maps use `name`, `uddfId`, `latitude`, `longitude`. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/services/divelogs_dive_mapper.dart'; + +void main() { + const mapper = DivelogsDiveMapper(); + + DivelogsDive dive({ + String? id = '4711', + String? siteName = 'Shinenead', + double? lat = 24.6, + double? lng = 35.1, + }) => DivelogsDive( + id: id, + dateTime: DateTime(2022, 9, 3, 14, 42), + durationSeconds: 2808, + maxDepth: 12, + meanDepth: 7.9, + sampleRateSeconds: 10, + samples: const [ + DivelogsSample(depth: 1, temperature: 13), + DivelogsSample(depth: 10), + ], + tanks: const [ + DivelogsTank( + o2: 28, + he: 0, + startPressure: 214.56, + endPressure: 103, + volume: 12, + workingPressure: 200, + ), + ], + buddy: 'Buddy', + siteName: siteName, + location: 'Aegypten, Rotes Meer', + notes: 'nice dive', + weather: 'sunny', + visibility: 'good', + dcModel: 'Suunto D6', + latitude: lat, + longitude: lng, + airTemp: 28, + depthTemp: 21, + surfaceTemp: 26, + weightsKg: 4, + surfaceIntervalSeconds: 3600, + ); + + test('maps core fields with importer-compatible keys', () { + final map = mapper.mapDive(dive()); + expect(map['dateTime'], DateTime(2022, 9, 3, 14, 42)); + expect(map['runtime'], const Duration(seconds: 2808)); + expect(map['maxDepth'], 12.0); + expect(map['avgDepth'], 7.9); + expect(map['waterTemp'], 21.0); // depthtemp wins over surfacetemp + expect(map['airTemp'], 28.0); + expect(map['buddy'], 'Buddy'); + expect(map['buddyRefs'], ['Buddy']); + expect(map['weightUsed'], 4.0); + expect(map['latitude'], 24.6); + expect(map['longitude'], 35.1); + expect(map['diveComputerModel'], 'Suunto D6'); + expect(map['surfaceInterval'], const Duration(seconds: 3600)); + expect(map['sourceUuid'], 'divelogs:4711'); + }); + + test('appends weather, visibility, and location to notes', () { + final notes = mapper.mapDive(dive())['notes'] as String; + expect(notes, contains('nice dive')); + expect(notes, contains('Weather: sunny')); + expect(notes, contains('Visibility: good')); + expect(notes, contains('Location: Aegypten, Rotes Meer')); + }); + + test('builds profile from samples using samplerate', () { + final profile = mapper.mapDive(dive())['profile'] as List; + expect(profile, hasLength(2)); + expect(profile[0], {'timestamp': 0, 'depth': 1.0, 'temperature': 13.0}); + expect(profile[1]['timestamp'], 10); + expect(profile[1].containsKey('temperature'), isFalse); + }); + + test('builds tank maps with GasMix and double volume', () { + final tanks = mapper.mapDive(dive())['tanks'] as List; + final tank = tanks.single as Map; + expect((tank['gasMix'] as GasMix).o2, 28.0); + expect(tank['startPressure'], 214.56); + expect(tank['endPressure'], 103.0); + expect(tank['volume'], isA()); + expect(tank['workingPressure'], 200.0); + }); + + test('links dive to site entity via uddfId and mapSite emits site map', () { + final d = dive(); + final map = mapper.mapDive(d); + final site = mapper.mapSite(d)!; + expect((map['site'] as Map)['uddfId'], site['uddfId']); + expect(site['name'], 'Shinenead'); + expect(site['latitude'], 24.6); + expect(site['longitude'], 35.1); + }); + + test('no sourceUuid key when remote id missing', () { + expect(mapper.mapDive(dive(id: null)).containsKey('sourceUuid'), isFalse); + }); + + test('no site when name missing', () { + final d = dive(siteName: null); + expect(mapper.mapSite(d), isNull); + expect(mapper.mapDive(d).containsKey('site'), isFalse); + }); + + test('zero weights and temps are treated as unset', () { + final d = DivelogsDive( + dateTime: DateTime(2022), + durationSeconds: 60, + maxDepth: 5, + weightsKg: 0, + airTemp: 0, + ); + final map = mapper.mapDive(d); + expect(map.containsKey('weightUsed'), isFalse); + expect(map.containsKey('airTemp'), isFalse); + }); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `flutter test test/features/universal_import/data/services/divelogs_dive_mapper_test.dart` +Expected: FAIL — mapper missing. + +- [ ] **Step 3: Implement the mapper** + +```dart +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +/// Converts divelogs.de API dives into the untyped entity maps consumed by +/// the universal import pipeline (UddfEntityImporter key conventions). +/// +/// divelogs.de uses 0 for "not set" on numeric optionals (temps, weights); +/// those are dropped rather than imported as literal zeros. +class DivelogsDiveMapper { + const DivelogsDiveMapper(); + + static String siteKey(String name) => + 'divelogs-site-${name.trim().toLowerCase()}'; + + Map mapDive(DivelogsDive dive) { + final map = { + 'dateTime': dive.dateTime, + 'runtime': Duration(seconds: dive.durationSeconds), + 'maxDepth': dive.maxDepth, + if (dive.meanDepth != null && dive.meanDepth! > 0) + 'avgDepth': dive.meanDepth, + 'notes': _buildNotes(dive), + }; + + final waterTemp = _positive(dive.depthTemp) ?? _positive(dive.surfaceTemp); + if (waterTemp != null) map['waterTemp'] = waterTemp; + final airTemp = _positive(dive.airTemp); + if (airTemp != null) map['airTemp'] = airTemp; + final weight = _positive(dive.weightsKg); + if (weight != null) map['weightUsed'] = weight; + + if (dive.buddy != null) { + map['buddy'] = dive.buddy; + map['buddyRefs'] = [dive.buddy!]; + } + if (dive.latitude != null && dive.longitude != null) { + map['latitude'] = dive.latitude; + map['longitude'] = dive.longitude; + } + if (dive.dcModel != null) map['diveComputerModel'] = dive.dcModel; + if (dive.surfaceIntervalSeconds != null && + dive.surfaceIntervalSeconds! > 0) { + map['surfaceInterval'] = Duration(seconds: dive.surfaceIntervalSeconds!); + } + if (dive.id != null) map['sourceUuid'] = 'divelogs:${dive.id}'; + + final siteName = dive.siteName; + if (siteName != null) { + map['siteName'] = siteName; + map['site'] = { + 'uddfId': siteKey(siteName), + 'name': siteName, + }; + } + + final tanks = dive.tanks + .map( + (t) => { + 'gasMix': GasMix(o2: t.o2 ?? 21.0, he: t.he ?? 0.0), + if (t.startPressure != null) 'startPressure': t.startPressure, + if (t.endPressure != null) 'endPressure': t.endPressure, + if (t.volume != null && t.volume! > 0) + 'volume': t.volume!.toDouble(), + if (t.workingPressure != null && t.workingPressure! > 0) + 'workingPressure': t.workingPressure, + if (t.name != null) 'name': t.name, + }, + ) + .toList(); + if (tanks.isNotEmpty) map['tanks'] = tanks; + + final rate = dive.sampleRateSeconds; + if (dive.samples.isNotEmpty && rate != null && rate > 0) { + map['profile'] = [ + for (var i = 0; i < dive.samples.length; i++) + { + 'timestamp': i * rate, + 'depth': dive.samples[i].depth, + if (dive.samples[i].temperature != null) + 'temperature': dive.samples[i].temperature, + }, + ]; + } + + return map; + } + + /// Site entity map for the payload, or null when the dive has no site name. + Map? mapSite(DivelogsDive dive) { + final name = dive.siteName; + if (name == null) return null; + return { + 'uddfId': siteKey(name), + 'name': name, + if (dive.latitude != null) 'latitude': dive.latitude, + if (dive.longitude != null) 'longitude': dive.longitude, + }; + } + + double? _positive(double? value) => + (value != null && value > 0) ? value : null; + + String _buildNotes(DivelogsDive dive) { + final parts = [ + if (dive.notes != null) dive.notes!, + if (dive.weather != null) 'Weather: ${dive.weather}', + if (dive.visibility != null) 'Visibility: ${dive.visibility}', + if (dive.boat != null) 'Boat: ${dive.boat}', + if (dive.location != null) 'Location: ${dive.location}', + ]; + return parts.join('\n'); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `flutter test test/features/universal_import/data/services/divelogs_dive_mapper_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib/features/universal_import test/features/universal_import +git commit -m "feat: map divelogs.de dives into universal import entity maps" +``` + +--- + +### Task 7: `DivelogsImportService` (payload assembly) + duplicate-checker integration test + +**Files:** +- Create: `lib/features/universal_import/data/services/divelogs_import_service.dart` +- Test: `test/features/universal_import/data/services/divelogs_import_service_test.dart` + +**Interfaces:** +- Consumes: Task 4 (`DivelogsApiClient`, `DivelogsDivesResult`), Task 6 (`DivelogsDiveMapper`), `ImportPayload`/`ImportWarning`/`ImportEntityType` from `lib/features/universal_import/data/models/`. +- Produces: `class DivelogsImportService { DivelogsImportService({required DivelogsApiClient api}); Future fetchAllDives(); }` — payload with `ImportEntityType.dives` + `ImportEntityType.sites` (deduped by `uddfId`), warning entry when dives were skipped, `metadata: {'source': 'divelogs.de', 'diveCount': N}`. + +- [ ] **Step 1: Write the failing test** + +Use a `MockClient`-backed `DivelogsApiClient` (no network). Include: two dives sharing one site → payload has 2 dive maps and 1 site map; skipped-dive warning surfaces; **duplicate-checker integration**: feed the produced payload plus a matching existing `Dive` into `const ImportDuplicateChecker().check(...)` and assert (a) fuzzy date/time match flags the duplicate, (b) with `existingSourceUuidByDiveId: {'existing-1': 'divelogs:4711'}` Pass-0 flags it with `matchedExistingSource: true` (this is the second-pull idempotency guarantee). Model the checker invocation on `test/features/universal_import/data/services/import_duplicate_checker_test.dart` (empty lists for the non-dive entity params). + +```dart +// Key assertions (structure the file like import_duplicate_checker_test.dart): +final payload = await service.fetchAllDives(); +expect(payload.entitiesOf(ImportEntityType.dives), hasLength(2)); +expect(payload.entitiesOf(ImportEntityType.sites), hasLength(1)); + +final result = const ImportDuplicateChecker().check( + payload: payload, + existingDives: [existingDive], // same start time/depth/duration as dive 1 + existingSites: const [], existingTrips: const [], + existingEquipment: const [], existingBuddies: const [], + existingDiveCenters: const [], existingCertifications: const [], + existingTags: const [], existingDiveTypes: const [], +); +expect(result.duplicates[ImportEntityType.dives], contains(0)); + +final pass0 = const ImportDuplicateChecker().check( + payload: payload, + existingDives: [existingDive], + existingSourceUuidByDiveId: {existingDive.id: 'divelogs:4711'}, + // ... same empty lists ... +); +expect(pass0.diveMatches[0]?.matchedExistingSource, isTrue); +``` + +- [ ] **Step 2: Run to verify failure, then implement** + +```dart +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/divelogs_dive_mapper.dart'; + +/// Fetches the full divelogs.de logbook and assembles an ImportPayload for +/// the universal import pipeline. +class DivelogsImportService { + DivelogsImportService({ + required DivelogsApiClient api, + DivelogsDiveMapper mapper = const DivelogsDiveMapper(), + }) : _api = api, + _mapper = mapper; + + final DivelogsApiClient _api; + final DivelogsDiveMapper _mapper; + + Future fetchAllDives() async { + final result = await _api.getAllDives(); + + final diveEntities = >[]; + final sitesByKey = >{}; + for (final dive in result.dives) { + diveEntities.add(_mapper.mapDive(dive)); + final site = _mapper.mapSite(dive); + if (site != null) { + sitesByKey.putIfAbsent(site['uddfId'] as String, () => site); + } + } + + final entities = >>{}; + if (diveEntities.isNotEmpty) { + entities[ImportEntityType.dives] = diveEntities; + } + if (sitesByKey.isNotEmpty) { + entities[ImportEntityType.sites] = sitesByKey.values.toList(); + } + + return ImportPayload( + entities: entities, + warnings: [ + if (result.skippedCount > 0) + ImportWarning( + severity: ImportWarningSeverity.warning, + message: + '${result.skippedCount} dives could not be read from ' + 'divelogs.de and were skipped.', + ), + ], + metadata: {'source': 'divelogs.de', 'diveCount': result.dives.length}, + ); + } +} +``` + +(Check `ImportWarning`'s actual constructor in `lib/features/universal_import/data/models/` — match its required params, e.g. an `ImportEntityType`/context field if present, by copying an existing construction site from `shearwater_cloud_parser.dart`.) + +- [ ] **Step 3: Run tests** + +Run: `flutter test test/features/universal_import/data/services/divelogs_import_service_test.dart` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +dart format . +git add -A lib/features/universal_import test/features/universal_import +git commit -m "feat: assemble divelogs.de import payload with dedup-ready source uuids" +``` + +--- + +### Task 8: Wizard adapter + sign-in/fetch step + +**Files:** +- Modify: `lib/features/import_wizard/domain/models/import_bundle.dart` (add `ImportSourceType.divelogs`; run `flutter analyze` and add cases to any exhaustive switches over `ImportSourceType` it reports) +- Modify: `lib/features/universal_import/presentation/providers/universal_import_providers.dart` (add `setExternalPayload`) +- Create: `lib/features/import_wizard/data/adapters/divelogs_adapter.dart` +- Create: `lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart` +- Test: `test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart` + +**Interfaces:** +- Consumes: Tasks 1–7; `UniversalAdapter` (`lib/features/import_wizard/data/adapters/universal_adapter.dart`), `WizardStepDef` (`lib/shared/widgets/wizard/wizard_step_def.dart`), `universalImportNotifierProvider`, `connectedAccountsRepositoryProvider`, `accountCredentialsStoreProvider`, `accountProviderRegistryProvider`, `allDiversProvider` (`lib/features/divers/presentation/providers/diver_providers.dart`). +- Produces: `class DivelogsImportAdapter extends UniversalAdapter` (`sourceType => ImportSourceType.divelogs`, one acquisition step); `divelogsPayloadReadyProvider` (`Provider`); `DivelogsFetchStep` widget. + +- [ ] **Step 1: Add `setExternalPayload` to `UniversalImportNotifier`** + +In `universal_import_providers.dart`, add a public method that mirrors the state update at the end of the file-parse completion path (the block around lines 664–724 that sets `payload`, duplicate results, and default selections — reuse `_checkDuplicates` and `_defaultSelections`, copy the `state = state.copyWith(...)` field list from that block exactly, substituting `sourceLabel` for the file name): + +```dart +/// Installs a payload produced outside the file-parse path (e.g. a REST +/// source like divelogs.de) and runs the standard duplicate check and +/// default-selection pass so the wizard can proceed to review. +Future setExternalPayload( + ImportPayload payload, { + String? sourceLabel, +}) async { + final dupResult = await _checkDuplicates(payload); + final selections = _defaultSelections(payload, dupResult); + state = state.copyWith( + // copy the exact field list from the parse-completion state update, + // with payload/dupResult/selections and fileName: sourceLabel + ); +} +``` + +Write a notifier-level test only if `universal_import_providers` already has one (extend it); otherwise the widget test in Step 3 covers this path. + +- [ ] **Step 2: Add the adapter** + +`lib/features/import_wizard/data/adapters/divelogs_adapter.dart`: + +```dart +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/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; + +final divelogsPayloadReadyProvider = Provider( + (ref) => ref.watch(universalImportNotifierProvider).payload != null, +); + +/// Import source that pulls the user's logbook from divelogs.de. +/// +/// Reuses the entire universal pipeline (bundle building, duplicate check, +/// commit); only acquisition differs: sign in and fetch instead of a file. +class DivelogsImportAdapter extends UniversalAdapter { + DivelogsImportAdapter({required super.ref}) + : super(displayName: 'divelogs.de'); + + @override + ImportSourceType get sourceType => ImportSourceType.divelogs; + + @override + List get acquisitionSteps => [ + WizardStepDef( + label: 'Sign In', + icon: Icons.cloud_download_outlined, + builder: (context) => const DivelogsFetchStep(), + canAdvance: divelogsPayloadReadyProvider, + autoAdvance: true, + ), + ]; +} +``` + +(Match `UniversalAdapter`'s actual constructor — if it's `UniversalAdapter({required WidgetRef ref, String? displayName})`, use `super(ref: ref, displayName: 'divelogs.de')`. If `displayName`/`sourceType`/`acquisitionSteps` are not overridable members, make them so — they are plain getters on the base class.) + +- [ ] **Step 3: Build the fetch step widget (test-first)** + +`DivelogsFetchStep` is a `ConsumerStatefulWidget` with two visual states: + +1. **Not connected** (no `AccountKind.divelogs` account, or adapter `status()` is `needsSignIn`): form with username field, password field (obscured), diver dropdown (`allDiversProvider`, default: current active diver — resolve via the same provider `UniversalAdapter.checkDuplicates` uses for `diverId`; grep `diverId` in `universal_adapter.dart` and reuse that provider), and a Connect button. On submit: + - `DivelogsAuthManager.login(username:, password:)` — validates; on `DivelogsAuthException` show the message inline and stay. + - Create the account if absent: `repo.create(kind: AccountKind.divelogs, label: 'divelogs.de', accountIdentifier: username, diverId: selectedDiverId)`. + - Persist credentials: `accountCredentialsStore.write(account.id, DivelogsCredentials(username: ..., password: ..., bearerToken: token).toJsonString())`. + - Proceed to fetch (state 2). +2. **Connected**: shows "Fetching dives from divelogs.de..." with a progress indicator, immediately runs: + ```dart + final adapter = ref.read(accountProviderRegistryProvider) + .adapterFor(AccountKind.divelogs) as DivelogsAccountAdapter; + final manager = adapter.authManagerFor(account); + final api = DivelogsApiClient( + getBearerToken: manager.getToken, + onTokenRejected: manager.invalidateToken, + ); + final payload = await DivelogsImportService(api: api).fetchAllDives(); + await ref.read(universalImportNotifierProvider.notifier) + .setExternalPayload(payload, sourceLabel: 'divelogs.de'); + ``` + On success the `canAdvance` provider flips true and the wizard auto-advances. On `DivelogsApiException` show the message with a Retry button. If the bound `account.diverId` is non-null and differs from the active diver, show a blocking message ("This divelogs.de account is linked to a different diver profile. Switch divers to import.") instead of fetching. + +Widget test (`divelogs_fetch_step_test.dart`): pump the step inside a `ProviderScope` with `accountCredentialsStoreProvider` overridden to an `InMemoryKeychain`-backed store and `connectedAccountsRepositoryProvider` overridden to an in-memory fake (copy the override pattern from `test/features/settings/presentation/pages/connected_accounts_page_test.dart` if present, else from any existing widget test that overrides these providers — locate with `grep -rl "accountCredentialsStoreProvider" test/`). Assert: (a) form shows when no account exists; (b) invalid login (MockClient 401) surfaces the error text and creates no account; (c) successful login creates the account with the selected `diverId` and stores the credentials blob. Follow the repo's widget-test gotchas: `themeAnimationDuration: Duration.zero`, `tester.ensureVisible` before taps, and wrap drift-touching awaits in `tester.runAsync`. + +- [ ] **Step 4: Run tests and analyze** + +Run: `flutter test test/features/import_wizard/ && flutter analyze` +Expected: PASS, no errors. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: add divelogs.de import wizard adapter with sign-in and fetch step" +``` + +--- + +### Task 9: Route, transfer-page entry, and localization + +**Files:** +- Modify: `lib/core/router/app_router.dart` (route + wrapper near `_UniversalImportWizardRoute`, ~line 1341) +- Modify: `lib/features/transfer/presentation/pages/transfer_page.dart` (`_ImportSectionContent`, ~line 201) +- Modify: `lib/l10n/arb/app_en.arb` + all 10 non-English arb files (es, fr, de, it, nl, pt, hu, he, zh, ar) +- Test: extend `test/features/transfer/presentation/pages/transfer_page_test.dart` if it exists (locate with `ls test/features/transfer/`); otherwise router smoke coverage comes from the analyzer + existing router tests. + +**Interfaces:** +- Consumes: Task 8's `DivelogsImportAdapter`. +- Produces: route `/transfer/divelogs-import` (name `divelogsImport`); transfer-page tile. + +- [ ] **Step 1: Add the route wrapper and GoRoute** + +In `app_router.dart`, next to `_UniversalImportWizardRoute`: + +```dart +class _DivelogsImportWizardRoute extends ConsumerWidget { + const _DivelogsImportWizardRoute(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return UnifiedImportWizard(adapter: DivelogsImportAdapter(ref: ref)); + } +} +``` + +And under the `/transfer` routes (next to `import-wizard`): + +```dart +GoRoute( + path: 'divelogs-import', + name: 'divelogsImport', + builder: (context, state) => const _DivelogsImportWizardRoute(), +), +``` + +- [ ] **Step 2: Add the transfer-page tile** + +In `_ImportSectionContent`, duplicate the existing File Import `Card` block, with icon `Icons.travel_explore_outlined`, title `context.l10n.transfer_import_divelogs_title`, subtitle `context.l10n.transfer_import_divelogs_subtitle`, and `onTap: () => context.push('/transfer/divelogs-import')`. + +- [ ] **Step 3: Add localization strings** + +In `app_en.arb` (match the neighboring `transfer_import_*` key style): + +```json +"transfer_import_divelogs_title": "Import from divelogs.de", +"transfer_import_divelogs_subtitle": "Pull your logbook from your divelogs.de account", +"divelogs_signIn_title": "Sign in to divelogs.de", +"divelogs_signIn_username": "Username", +"divelogs_signIn_password": "Password", +"divelogs_signIn_diver": "Import into diver", +"divelogs_signIn_connect": "Connect", +"divelogs_signIn_failed": "Could not sign in: {error}", +"@divelogs_signIn_failed": { "placeholders": { "error": { "type": "String" } } }, +"divelogs_fetch_inProgress": "Fetching dives from divelogs.de...", +"divelogs_fetch_retry": "Retry", +"divelogs_fetch_wrongDiver": "This divelogs.de account is linked to a different diver profile. Switch divers to import." +``` + +Translate every key into all 10 non-English arb files ("divelogs.de" stays untranslated; German translations matter most — divelogs.de's home audience). Replace any hard-coded strings from Task 8's widget with these keys. Run `flutter gen-l10n`. + +- [ ] **Step 4: Verify** + +Run: `flutter analyze && flutter test test/features/import_wizard/ test/features/transfer/ 2>/dev/null || flutter test test/features/import_wizard/` +Expected: no analyze errors; tests PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: add divelogs.de import entry point, route, and translations" +``` + +--- + +### Task 10: Full verification sweep + +**Files:** none new. + +- [ ] **Step 1: Format and analyze the whole project** + +Run: `dart format . && flutter analyze` +Expected: format changes nothing; analyze reports no errors. Fix anything reported. + +- [ ] **Step 2: Run the touched test surface** + +```bash +flutter test \ + test/core/services/divelogs \ + test/core/services/accounts \ + test/core/providers/account_providers_test.dart \ + test/features/universal_import/data/services \ + test/features/import_wizard +``` +Expected: all PASS. + +- [ ] **Step 3: Manual smoke (macOS)** + +Check no other `flutter run -d macos` session is active first. Launch, then: Transfer → Import from divelogs.de → sign in with a real or test account (or verify the error path with bad credentials) → confirm the review step lists fetched dives → import a couple → re-run the import and confirm they show as already-imported (Pass-0). If no real account is available, note the smoke as pending in the PR description. + +- [ ] **Step 4: Commit any fixes** + +```bash +dart format . +git add -A +git commit -m "test: divelogs.de phase 1 verification fixes" +``` + +(Do not push or open a PR — Phase 1 review and PR creation is a separate, user-triggered step.) + +--- + +## Deferred to later phases (do NOT build now) + +- `GET /divelist` compare, `DivelogsSyncPlanner`, sync page UI (Phase 2) +- Push mapping/`POST /dives` (Phase 2) +- Gear, certifications (Phase 3), pictures (Phase 4) +- `LogbookSyncCapable` members beyond the marker (Phase 2) + +## Open assumptions to confirm with Rainer (do not block) + +- Login response token field name (`bearer_token` assumed; client also tries `token`, `access_token`) +- `GET /dives` returns a JSON array (object-with-`dives`-key tolerated) +- Units are metric; `0` means "not set" for temps/weights +- Remote dive `id` field present in GET responses From 37d6093759356a28c9be366909267cbe578d9815 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:47:58 -0400 Subject: [PATCH 03/35] feat: add AccountKind.divelogs connector kind --- lib/core/services/accounts/account_kind.dart | 11 +++++++---- .../accounts/account_startup_migration.dart | 2 ++ .../pages/connected_accounts_page.dart | 1 + .../presentation/providers/sync_providers.dart | 3 ++- .../services/accounts/account_kind_test.dart | 17 +++++++++++++++++ 5 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 test/core/services/accounts/account_kind_test.dart diff --git a/lib/core/services/accounts/account_kind.dart b/lib/core/services/accounts/account_kind.dart index 0fe439cf2d..cf3e7c0dfc 100644 --- a/lib/core/services/accounts/account_kind.dart +++ b/lib/core/services/accounts/account_kind.dart @@ -1,23 +1,26 @@ import 'package:submersion/core/data/repositories/sync_repository.dart'; /// The kinds of endpoints a ConnectedAccount can represent. The first four -/// mirror [CloudProviderType]; connector kinds (Lightroom now, Immich/SMB -/// later per the program spec) have no cloud provider equivalent. +/// mirror [CloudProviderType]; connector kinds (Lightroom and divelogs.de +/// now, Immich/SMB later per the program spec) have no cloud provider +/// equivalent. enum AccountKind { dropbox, googledrive, icloud, s3, - adobeLightroom; + adobeLightroom, + divelogs; /// The sync/media-store provider this kind corresponds to, or null for - /// media-source connector kinds. + /// connector kinds. CloudProviderType? get cloudProviderType => switch (this) { AccountKind.dropbox => CloudProviderType.dropbox, AccountKind.googledrive => CloudProviderType.googledrive, AccountKind.icloud => CloudProviderType.icloud, AccountKind.s3 => CloudProviderType.s3, AccountKind.adobeLightroom => null, + AccountKind.divelogs => null, }; static AccountKind fromCloudProviderType(CloudProviderType type) => diff --git a/lib/core/services/accounts/account_startup_migration.dart b/lib/core/services/accounts/account_startup_migration.dart index 7194152fc6..076301c208 100644 --- a/lib/core/services/accounts/account_startup_migration.dart +++ b/lib/core/services/accounts/account_startup_migration.dart @@ -100,6 +100,7 @@ class AccountStartupMigration { case AccountKind.googledrive: case AccountKind.icloud: case AccountKind.adobeLightroom: + case AccountKind.divelogs: break; // Session-managed or not a sync kind: nothing to re-key. } @@ -189,5 +190,6 @@ class AccountStartupMigration { AccountKind.icloud => 'iCloud', AccountKind.s3 => 'S3', AccountKind.adobeLightroom => 'Lightroom', + AccountKind.divelogs => 'divelogs.de', }; } diff --git a/lib/features/settings/presentation/pages/connected_accounts_page.dart b/lib/features/settings/presentation/pages/connected_accounts_page.dart index 11e0a187c5..f3630e3def 100644 --- a/lib/features/settings/presentation/pages/connected_accounts_page.dart +++ b/lib/features/settings/presentation/pages/connected_accounts_page.dart @@ -79,6 +79,7 @@ class _AccountTile extends ConsumerWidget { AccountKind.googledrive => Icons.add_to_drive_outlined, AccountKind.icloud => Icons.cloud_circle_outlined, AccountKind.adobeLightroom => Icons.photo_library_outlined, + AccountKind.divelogs => Icons.travel_explore_outlined, }; @override diff --git a/lib/features/settings/presentation/providers/sync_providers.dart b/lib/features/settings/presentation/providers/sync_providers.dart index b379503511..c01066b4d9 100644 --- a/lib/features/settings/presentation/providers/sync_providers.dart +++ b/lib/features/settings/presentation/providers/sync_providers.dart @@ -345,7 +345,8 @@ Future _mirrorLegacyCredentials( // Session-managed / not a sync kind: no keychain blob to mirror. AccountKind.googledrive || AccountKind.icloud || - AccountKind.adobeLightroom => null, + AccountKind.adobeLightroom || + AccountKind.divelogs => null, }; if (legacyKey == null) return; await ref diff --git a/test/core/services/accounts/account_kind_test.dart b/test/core/services/accounts/account_kind_test.dart new file mode 100644 index 0000000000..b59fc33b03 --- /dev/null +++ b/test/core/services/accounts/account_kind_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; + +void main() { + group('AccountKind.divelogs', () { + test('has no cloud provider type (connector kind)', () { + expect(AccountKind.divelogs.cloudProviderType, isNull); + }); + + test('round-trips through name for DB persistence', () { + expect( + AccountKind.values.byName(AccountKind.divelogs.name), + AccountKind.divelogs, + ); + }); + }); +} From 845320124fd08a6c5692c979a2d663d55ce630c6 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:52:21 -0400 Subject: [PATCH 04/35] feat: bind connected accounts to a diver via diver_id column (v115) --- .../connected_accounts_repository.dart | 4 +++ lib/core/database/database.dart | 31 +++++++++++++++++-- .../services/accounts/connected_account.dart | 6 ++++ .../connected_accounts_repository_test.dart | 17 ++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lib/core/data/repositories/connected_accounts_repository.dart b/lib/core/data/repositories/connected_accounts_repository.dart index c8e029c0e3..96e077f8cc 100644 --- a/lib/core/data/repositories/connected_accounts_repository.dart +++ b/lib/core/data/repositories/connected_accounts_repository.dart @@ -32,6 +32,7 @@ class ConnectedAccountsRepository { required String label, String? accountIdentifier, String? id, + String? diverId, }) async { final accountId = id ?? _uuid.v4(); final now = DateTime.now().millisecondsSinceEpoch; @@ -45,6 +46,7 @@ class ConnectedAccountsRepository { accountIdentifier: Value(accountIdentifier), createdAt: now, updatedAt: now, + diverId: Value(diverId), ), ); await _markPending(accountId, now); @@ -55,6 +57,7 @@ class ConnectedAccountsRepository { accountIdentifier: accountIdentifier, createdAt: DateTime.fromMillisecondsSinceEpoch(now, isUtc: true), updatedAt: DateTime.fromMillisecondsSinceEpoch(now, isUtc: true), + diverId: diverId, ); } @@ -140,6 +143,7 @@ class ConnectedAccountsRepository { row.updatedAt, isUtc: true, ), + diverId: row.diverId, ); } } diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 86f5f064f0..ee1ce4e07a 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1110,6 +1110,10 @@ class ConnectedAccounts extends Table { IntColumn get updatedAt => integer()(); TextColumn get hlc => text().nullable()(); + /// Diver this account is bound to; used by connector kinds whose data is + /// per-diver (divelogs.de). Null for library-wide kinds (sync, media). + TextColumn get diverId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -2205,7 +2209,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 = 112; + static const int currentSchemaVersion = 115; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -2360,7 +2364,8 @@ class AppDatabase extends _$AppDatabase { 'account_identifier TEXT, ' 'created_at INTEGER NOT NULL, ' 'updated_at INTEGER NOT NULL, ' - 'hlc TEXT)', + 'hlc TEXT, ' + 'diver_id TEXT)', ); final metaCols = await customSelect( "PRAGMA table_info('sync_metadata')", @@ -2415,6 +2420,21 @@ class AppDatabase extends _$AppDatabase { } } + /// v115: connected_accounts.diver_id column (divelogs.de diver binding). + /// Idempotent so it is safe to call from both onUpgrade and the beforeOpen + /// backstop. + Future _assertConnectedAccountsDiverIdColumn() async { + final cols = await customSelect( + "PRAGMA table_info('connected_accounts')", + ).get(); + final hasDiverId = cols.any((c) => c.read('name') == 'diver_id'); + if (cols.isNotEmpty && !hasDiverId) { + await customStatement( + 'ALTER TABLE connected_accounts ADD COLUMN diver_id TEXT', + ); + } + } + /// 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. @@ -5543,6 +5563,10 @@ class AppDatabase extends _$AppDatabase { await _assertEquipmentThicknessColumn(); } if (from < 112) await reportProgress(); + if (from < 115) { + await _assertConnectedAccountsDiverIdColumn(); + } + if (from < 115) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -5576,6 +5600,9 @@ class AppDatabase extends _$AppDatabase { // v112 backstop: re-assert equipment.thickness column. await _assertEquipmentThicknessColumn(); + // v115 backstop: re-assert connected_accounts.diver_id column. + await _assertConnectedAccountsDiverIdColumn(); + // Built-in dive types are reference data: identical on every device and // undeletable through DiveTypeRepository. Nothing else restores them -- // the seed runs only in onCreate and the one-shot v93 step -- yet a diff --git a/lib/core/services/accounts/connected_account.dart b/lib/core/services/accounts/connected_account.dart index 0261b555fb..33b83f1f1e 100644 --- a/lib/core/services/accounts/connected_account.dart +++ b/lib/core/services/accounts/connected_account.dart @@ -11,6 +11,10 @@ class ConnectedAccount { final DateTime createdAt; final DateTime updatedAt; + /// Diver this account is bound to (connector kinds with per-diver data, + /// e.g. divelogs.de). Null for library-wide kinds. Fixed at creation. + final String? diverId; + const ConnectedAccount({ required this.id, required this.kind, @@ -18,6 +22,7 @@ class ConnectedAccount { this.accountIdentifier, required this.createdAt, required this.updatedAt, + this.diverId, }); /// Keychain key for this account's credentials blob. @@ -35,6 +40,7 @@ class ConnectedAccount { accountIdentifier: accountIdentifier ?? this.accountIdentifier, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, + diverId: diverId, ); } } diff --git a/test/core/data/repositories/connected_accounts_repository_test.dart b/test/core/data/repositories/connected_accounts_repository_test.dart index 17ba08c3cb..288e4729e3 100644 --- a/test/core/data/repositories/connected_accounts_repository_test.dart +++ b/test/core/data/repositories/connected_accounts_repository_test.dart @@ -63,6 +63,23 @@ void main() { expect(hlc.data['hlc'], isNotNull, reason: 'HLC must be stamped'); }); + test('create persists and round-trips diverId', () async { + final created = await repo.create( + kind: AccountKind.divelogs, + label: 'divelogs.de', + accountIdentifier: 'rainer', + diverId: 'diver-1', + ); + expect(created.diverId, 'diver-1'); + final loaded = await repo.getById(created.id); + expect(loaded!.diverId, 'diver-1'); + }); + + test('diverId defaults to null for kinds that do not bind one', () async { + final created = await repo.create(kind: AccountKind.s3, label: 'S3'); + expect((await repo.getById(created.id))!.diverId, isNull); + }); + test('getAll returns newest first; getByKind filters', () async { await repo.create(kind: AccountKind.s3, label: 'A'); await repo.create(kind: AccountKind.dropbox, label: 'B'); From 02dc4680edbf3aa0909f9785338304631d7abb47 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:53:51 -0400 Subject: [PATCH 05/35] feat: add divelogs.de credentials model and JWT auth manager --- .../divelogs/divelogs_auth_manager.dart | 134 ++++++++++++++++++ .../divelogs/divelogs_credentials.dart | 48 +++++++ .../divelogs/divelogs_auth_manager_test.dart | 128 +++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 lib/core/services/divelogs/divelogs_auth_manager.dart create mode 100644 lib/core/services/divelogs/divelogs_credentials.dart create mode 100644 test/core/services/divelogs/divelogs_auth_manager_test.dart diff --git a/lib/core/services/divelogs/divelogs_auth_manager.dart b/lib/core/services/divelogs/divelogs_auth_manager.dart new file mode 100644 index 0000000000..0950a8da7a --- /dev/null +++ b/lib/core/services/divelogs/divelogs_auth_manager.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; + +class DivelogsAuthException implements Exception { + final String message; + const DivelogsAuthException(this.message); + + @override + String toString() => 'DivelogsAuthException: $message'; +} + +/// Owns the divelogs.de JWT lifecycle for one connected account. +/// +/// divelogs.de has no OAuth: POST /login with username/password returns a +/// JWT. Renewal is 401-driven — the API client calls [invalidateToken] and +/// retries once, which triggers a fresh login here. +class DivelogsAuthManager { + DivelogsAuthManager({ + required AccountCredentialsStore credentials, + required this.accountId, + http.Client? httpClient, + }) : _credentials = credentials, + _http = httpClient ?? http.Client(); + + static final Uri loginUri = Uri.parse('https://divelogs.de/api/login'); + + final AccountCredentialsStore _credentials; + final String accountId; + final http.Client _http; + + String? _cachedToken; + bool _forceRelogin = false; + Future? _loginInFlight; + + /// Unauthenticated login. Used by the connect flow to validate credentials + /// before a ConnectedAccount exists, and internally for renewal. + static Future login({ + required String username, + required String password, + http.Client? httpClient, + }) async { + final client = httpClient ?? http.Client(); + final request = http.MultipartRequest('POST', loginUri) + ..fields['user'] = username + ..fields['pass'] = password; + final http.Response response; + try { + response = await http.Response.fromStream(await client.send(request)); + } on Exception { + throw const DivelogsAuthException('Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + throw const DivelogsAuthException( + 'divelogs.de rejected the username or password.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsAuthException( + 'divelogs.de login failed (HTTP ${response.statusCode}).', + ); + } + final token = _extractToken(response.body); + if (token == null) { + throw const DivelogsAuthException( + 'divelogs.de login response did not contain a token.', + ); + } + return token; + } + + static String? _extractToken(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + for (final key in const ['bearer_token', 'token', 'access_token']) { + final value = decoded[key]; + if (value is String && value.isNotEmpty) return value; + } + } + } on FormatException { + // fall through + } + return null; + } + + Future getToken() { + final cached = _cachedToken; + if (cached != null) return Future.value(cached); + return _loginInFlight ??= _resolveToken().whenComplete(() { + _loginInFlight = null; + }); + } + + Future _resolveToken() async { + final stored = DivelogsCredentials.fromJsonString( + await _credentials.read(accountId), + ); + if (stored == null) { + throw const DivelogsAuthException('Not signed in to divelogs.de.'); + } + final persisted = stored.bearerToken; + if (!_forceRelogin && persisted != null && persisted.isNotEmpty) { + _cachedToken = persisted; + return persisted; + } + final token = await login( + username: stored.username, + password: stored.password, + httpClient: _http, + ); + _forceRelogin = false; + _cachedToken = token; + await _credentials.write( + accountId, + stored.copyWith(bearerToken: token).toJsonString(), + ); + return token; + } + + /// Called by the API client when a request came back 401. + void invalidateToken() { + _cachedToken = null; + _forceRelogin = true; + } + + Future disconnect() async { + _cachedToken = null; + _forceRelogin = false; + await _credentials.delete(accountId); + } +} diff --git a/lib/core/services/divelogs/divelogs_credentials.dart b/lib/core/services/divelogs/divelogs_credentials.dart new file mode 100644 index 0000000000..6a8917f693 --- /dev/null +++ b/lib/core/services/divelogs/divelogs_credentials.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +/// Keychain payload for a divelogs.de account. +/// +/// The password is stored because divelogs.de issues expiring JWTs with no +/// refresh grant; re-login is the only renewal path. +class DivelogsCredentials { + final String username; + final String password; + final String? bearerToken; + + const DivelogsCredentials({ + required this.username, + required this.password, + this.bearerToken, + }); + + DivelogsCredentials copyWith({String? bearerToken}) => DivelogsCredentials( + username: username, + password: password, + bearerToken: bearerToken ?? this.bearerToken, + ); + + String toJsonString() => jsonEncode({ + 'username': username, + 'password': password, + if (bearerToken != null) 'bearerToken': bearerToken, + }); + + static DivelogsCredentials? fromJsonString(String? raw) { + if (raw == null || raw.isEmpty) return null; + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final username = decoded['username'] as String?; + final password = decoded['password'] as String?; + if (username == null || password == null) return null; + return DivelogsCredentials( + username: username, + password: password, + bearerToken: decoded['bearerToken'] as String?, + ); + } +} diff --git a/test/core/services/divelogs/divelogs_auth_manager_test.dart b/test/core/services/divelogs/divelogs_auth_manager_test.dart new file mode 100644 index 0000000000..1a320b41ee --- /dev/null +++ b/test/core/services/divelogs/divelogs_auth_manager_test.dart @@ -0,0 +1,128 @@ +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/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/divelogs/divelogs_auth_manager.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; + +import '../../../support/fake_keychain_storage.dart'; + +void main() { + late InMemoryKeychain keychain; + late AccountCredentialsStore store; + + setUp(() { + keychain = InMemoryKeychain(); + store = AccountCredentialsStore(storage: keychain); + }); + + MockClient loginOk({String token = 'jwt-1', List? log}) => + MockClient((req) async { + log?.add(req); + expect(req.url.path, '/api/login'); + return http.Response(jsonEncode({'bearer_token': token}), 200); + }); + + Future seedCreds({String? token}) => store.write( + 'acc-1', + DivelogsCredentials( + username: 'eric', + password: 'secret', + bearerToken: token, + ).toJsonString(), + ); + + test('login returns token from bearer_token field', () async { + final token = await DivelogsAuthManager.login( + username: 'eric', + password: 'secret', + httpClient: loginOk(), + ); + expect(token, 'jwt-1'); + }); + + test('login throws DivelogsAuthException on 401', () async { + final client = MockClient((_) async => http.Response('', 401)); + expect( + () => DivelogsAuthManager.login( + username: 'eric', + password: 'bad', + httpClient: client, + ), + throwsA(isA()), + ); + }); + + test('getToken uses persisted token without hitting network', () async { + await seedCreds(token: 'persisted'); + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: MockClient((_) async => fail('no network call expected')), + ); + expect(await manager.getToken(), 'persisted'); + }); + + test( + 'getToken logs in when no token persisted and persists result', + () async { + await seedCreds(); + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: loginOk(token: 'fresh'), + ); + expect(await manager.getToken(), 'fresh'); + final blob = DivelogsCredentials.fromJsonString( + await store.read('acc-1'), + ); + expect(blob?.bearerToken, 'fresh'); + }, + ); + + test('getToken is single-flight for concurrent callers', () async { + await seedCreds(); + final log = []; + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: loginOk(log: log), + ); + final results = await Future.wait([ + manager.getToken(), + manager.getToken(), + manager.getToken(), + ]); + expect(results.toSet(), {'jwt-1'}); + expect(log.length, 1); + }); + + test( + 'invalidateToken forces a fresh login ignoring persisted token', + () async { + await seedCreds(token: 'stale'); + final manager = DivelogsAuthManager( + credentials: store, + accountId: 'acc-1', + httpClient: loginOk(token: 'renewed'), + ); + expect(await manager.getToken(), 'stale'); + manager.invalidateToken(); + expect(await manager.getToken(), 'renewed'); + }, + ); + + test('disconnect deletes the credentials blob', () async { + await seedCreds(token: 't'); + final manager = DivelogsAuthManager(credentials: store, accountId: 'acc-1'); + await manager.disconnect(); + expect(await store.read('acc-1'), isNull); + }); + + test('getToken throws when not signed in', () async { + final manager = DivelogsAuthManager(credentials: store, accountId: 'acc-1'); + expect(() => manager.getToken(), throwsA(isA())); + }); +} From 9d6db7e09ea559e59d5f4fc48b12ed574eff28fc Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:55:26 -0400 Subject: [PATCH 06/35] feat: add divelogs.de REST API client with 401-retry and JSON models --- .../divelogs/divelogs_api_client.dart | 105 ++++++++++ .../services/divelogs/divelogs_models.dart | 194 ++++++++++++++++++ .../divelogs/divelogs_api_client_test.dart | 109 ++++++++++ .../divelogs/divelogs_models_test.dart | 101 +++++++++ 4 files changed, 509 insertions(+) create mode 100644 lib/core/services/divelogs/divelogs_api_client.dart create mode 100644 lib/core/services/divelogs/divelogs_models.dart create mode 100644 test/core/services/divelogs/divelogs_api_client_test.dart create mode 100644 test/core/services/divelogs/divelogs_models_test.dart diff --git a/lib/core/services/divelogs/divelogs_api_client.dart b/lib/core/services/divelogs/divelogs_api_client.dart new file mode 100644 index 0000000000..26f5672106 --- /dev/null +++ b/lib/core/services/divelogs/divelogs_api_client.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; + +class DivelogsApiException implements Exception { + final int statusCode; + final String message; + const DivelogsApiException(this.statusCode, this.message); + + @override + String toString() => 'DivelogsApiException($statusCode): $message'; +} + +/// Thin typed wrapper over the divelogs.de REST API. +/// +/// Auth is delegated to callbacks (mirrors DropboxApiClient): on 401 the +/// client calls [_onTokenRejected] (which invalidates the manager's token) +/// and retries exactly once with a freshly resolved token. +class DivelogsApiClient { + DivelogsApiClient({ + required Future Function() getBearerToken, + required void Function() onTokenRejected, + http.Client? httpClient, + Uri? baseUri, + }) : _getBearerToken = getBearerToken, + _onTokenRejected = onTokenRejected, + _http = httpClient ?? http.Client(), + _baseUri = baseUri ?? Uri.parse('https://divelogs.de/api'); + + final Future Function() _getBearerToken; + final void Function() _onTokenRejected; + final http.Client _http; + final Uri _baseUri; + + Future> getUser() async { + final response = await _get('/user'); + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const DivelogsApiException(0, 'Unexpected /user response'); + } + return Map.from(decoded); + } + + Future getAllDives() async { + final response = await _get('/dives'); + final decoded = jsonDecode(response.body); + final List rawDives; + if (decoded is List) { + rawDives = decoded; + } else if (decoded is Map && decoded['dives'] is List) { + rawDives = decoded['dives'] as List; + } else { + throw const DivelogsApiException(0, 'Unexpected /dives response'); + } + final dives = []; + var skipped = 0; + for (final raw in rawDives) { + if (raw is! Map) { + skipped++; + continue; + } + try { + dives.add(DivelogsDive.fromJson(Map.from(raw))); + } on FormatException { + skipped++; + } + } + return DivelogsDivesResult(dives: dives, skippedCount: skipped); + } + + Future _get(String path) async { + var authRetried = false; + while (true) { + final token = await _getBearerToken(); + final http.Response response; + try { + response = await _http.get( + _baseUri.replace(path: '${_baseUri.path}$path'), + headers: {'Authorization': 'Bearer $token'}, + ); + } on Exception { + throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + _onTokenRejected(); + if (!authRetried) { + authRetried = true; + continue; + } + throw const DivelogsApiException( + 401, + 'divelogs.de sign-in expired. Sign in again in Settings.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsApiException( + response.statusCode, + 'divelogs.de API error ${response.statusCode}', + ); + } + return response; + } + } +} diff --git a/lib/core/services/divelogs/divelogs_models.dart b/lib/core/services/divelogs/divelogs_models.dart new file mode 100644 index 0000000000..1de8b7967a --- /dev/null +++ b/lib/core/services/divelogs/divelogs_models.dart @@ -0,0 +1,194 @@ +/// Typed views over the divelogs.de REST API JSON. +/// +/// Field names and semantics follow the OpenAPI spec at +/// https://divelogs.de/api/docs/divelogs-openapi3.json. All values are +/// metric (meters, bar, Celsius, kg). +library; + +double? _asDouble(Object? v) => switch (v) { + num n => n.toDouble(), + String s => double.tryParse(s), + _ => null, +}; + +int? _asInt(Object? v) => switch (v) { + num n => n.toInt(), + String s => int.tryParse(s), + _ => null, +}; + +String? _asNonEmptyString(Object? v) { + if (v is! String) return null; + final trimmed = v.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +class DivelogsSample { + final double depth; + final double? temperature; + + const DivelogsSample({required this.depth, this.temperature}); +} + +class DivelogsTank { + final double? o2; + final double? he; + final double? startPressure; + final double? endPressure; + final double? volume; + final double? workingPressure; + final bool dbltank; + final String? name; + + const DivelogsTank({ + this.o2, + this.he, + this.startPressure, + this.endPressure, + this.volume, + this.workingPressure, + this.dbltank = false, + this.name, + }); + + factory DivelogsTank.fromJson(Map json) => DivelogsTank( + o2: _asDouble(json['o2']), + he: _asDouble(json['he']), + startPressure: _asDouble(json['start_pressure']), + endPressure: _asDouble(json['end_pressure']), + volume: _asDouble(json['vol']), + workingPressure: _asDouble(json['wp']), + dbltank: json['dbltank'] == true, + name: + _asNonEmptyString(json['tankname']) ?? _asNonEmptyString(json['tank']), + ); +} + +class DivelogsDive { + final String? id; + final DateTime dateTime; + final int durationSeconds; + final double maxDepth; + final double? meanDepth; + final int? sampleRateSeconds; + final List samples; + final List tanks; + final String? buddy; + final String? siteName; + final String? location; + final String? notes; + final String? weather; + final String? visibility; + final String? boat; + final String? dcModel; + final double? latitude; + final double? longitude; + final double? airTemp; + final double? depthTemp; + final double? surfaceTemp; + final double? weightsKg; + final int? surfaceIntervalSeconds; + + const DivelogsDive({ + this.id, + required this.dateTime, + required this.durationSeconds, + required this.maxDepth, + this.meanDepth, + this.sampleRateSeconds, + this.samples = const [], + this.tanks = const [], + this.buddy, + this.siteName, + this.location, + this.notes, + this.weather, + this.visibility, + this.boat, + this.dcModel, + this.latitude, + this.longitude, + this.airTemp, + this.depthTemp, + this.surfaceTemp, + this.weightsKg, + this.surfaceIntervalSeconds, + }); + + factory DivelogsDive.fromJson(Map json) { + final date = _asNonEmptyString(json['date']); + final time = _asNonEmptyString(json['time']) ?? '00:00:00'; + final duration = _asInt(json['duration']); + final maxDepth = _asDouble(json['maxdepth']); + if (date == null || duration == null || maxDepth == null) { + throw FormatException('divelogs dive missing mandatory fields', json); + } + final DateTime dateTime; + try { + dateTime = DateTime.parse('$date $time'); + } on FormatException { + throw FormatException('divelogs dive has unparseable date/time', json); + } + + final samples = []; + final sampleData = json['sampledata']; + if (sampleData is List) { + for (final entry in sampleData) { + if (entry is num) { + samples.add(DivelogsSample(depth: entry.toDouble())); + } else if (entry is Map) { + final d = _asDouble(entry['d']); + if (d != null) { + samples.add( + DivelogsSample(depth: d, temperature: _asDouble(entry['t'])), + ); + } + } + } + } + + final tanks = []; + final tanksJson = json['tanks']; + if (tanksJson is List) { + for (final t in tanksJson) { + if (t is Map) { + tanks.add(DivelogsTank.fromJson(Map.from(t))); + } + } + } + + final rawId = json['id'] ?? json['dive_id']; + return DivelogsDive( + id: rawId == null ? null : '$rawId', + dateTime: dateTime, + durationSeconds: duration, + maxDepth: maxDepth, + meanDepth: _asDouble(json['meandepth']), + sampleRateSeconds: _asInt(json['samplerate']), + samples: samples, + tanks: tanks, + buddy: _asNonEmptyString(json['buddy']), + siteName: _asNonEmptyString(json['divesite']), + location: _asNonEmptyString(json['location']), + notes: _asNonEmptyString(json['notes']), + weather: _asNonEmptyString(json['weather']), + visibility: _asNonEmptyString(json['visibility']), + boat: _asNonEmptyString(json['boat']), + dcModel: _asNonEmptyString(json['dc_model']), + latitude: _asDouble(json['lat']), + longitude: _asDouble(json['lng']), + airTemp: _asDouble(json['airtemp']), + depthTemp: _asDouble(json['depthtemp']), + surfaceTemp: _asDouble(json['surfacetemp']), + weightsKg: _asDouble(json['weights']), + surfaceIntervalSeconds: _asInt(json['surface_interval']), + ); + } +} + +class DivelogsDivesResult { + final List dives; + final int skippedCount; + + const DivelogsDivesResult({required this.dives, this.skippedCount = 0}); +} diff --git a/test/core/services/divelogs/divelogs_api_client_test.dart b/test/core/services/divelogs/divelogs_api_client_test.dart new file mode 100644 index 0000000000..b98b255890 --- /dev/null +++ b/test/core/services/divelogs/divelogs_api_client_test.dart @@ -0,0 +1,109 @@ +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/services/divelogs/divelogs_api_client.dart'; + +void main() { + Map diveJson(int id) => { + 'id': id, + 'date': '2022-09-03', + 'time': '14:42:00', + 'duration': 2808, + 'maxdepth': 12, + }; + + DivelogsApiClient client( + Future Function(http.Request) handler, { + void Function()? onRejected, + List? tokens, + }) { + final queue = List.from(tokens ?? ['t1']); + return DivelogsApiClient( + getBearerToken: () async => + queue.length > 1 ? queue.removeAt(0) : queue.first, + onTokenRejected: onRejected ?? () {}, + httpClient: MockClient(handler), + ); + } + + test('getAllDives sends bearer header and parses array body', () async { + late http.Request captured; + final api = client((req) async { + captured = req; + return http.Response(jsonEncode([diveJson(1), diveJson(2)]), 200); + }); + final result = await api.getAllDives(); + expect(captured.url.toString(), 'https://divelogs.de/api/dives'); + expect(captured.headers['Authorization'], 'Bearer t1'); + expect(result.dives, hasLength(2)); + expect(result.skippedCount, 0); + }); + + test('getAllDives tolerates object body with dives key', () async { + final api = client( + (req) async => http.Response( + jsonEncode({ + 'dives': [diveJson(1)], + }), + 200, + ), + ); + final result = await api.getAllDives(); + expect(result.dives, hasLength(1)); + }); + + test('getAllDives skips malformed dives and counts them', () async { + final api = client( + (req) async => http.Response( + jsonEncode([ + diveJson(1), + {'date': '2022-01-01'}, + ]), + 200, + ), + ); + final result = await api.getAllDives(); + expect(result.dives, hasLength(1)); + expect(result.skippedCount, 1); + }); + + test('401 invalidates token and retries exactly once', () async { + var rejections = 0; + var calls = 0; + final api = client( + (req) async { + calls++; + if (req.headers['Authorization'] == 'Bearer t1') { + return http.Response('', 401); + } + return http.Response(jsonEncode([diveJson(1)]), 200); + }, + onRejected: () => rejections++, + tokens: ['t1', 't2'], + ); + final result = await api.getAllDives(); + expect(result.dives, hasLength(1)); + expect(rejections, 1); + expect(calls, 2); + }); + + test('second 401 throws DivelogsApiException', () async { + final api = client((req) async => http.Response('', 401)); + expect( + () => api.getAllDives(), + throwsA( + isA().having((e) => e.statusCode, 'status', 401), + ), + ); + }); + + test('getUser returns decoded map', () async { + final api = client( + (req) async => http.Response(jsonEncode({'username': 'eric'}), 200), + ); + final user = await api.getUser(); + expect(user['username'], 'eric'); + }); +} diff --git a/test/core/services/divelogs/divelogs_models_test.dart b/test/core/services/divelogs/divelogs_models_test.dart new file mode 100644 index 0000000000..a390c3f1c4 --- /dev/null +++ b/test/core/services/divelogs/divelogs_models_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; + +void main() { + Map minimal() => { + 'id': 4711, + 'date': '2022-09-03', + 'time': '14:42:00', + 'duration': 2808, + 'maxdepth': 12, + }; + + test('parses mandatory fields', () { + final dive = DivelogsDive.fromJson(minimal()); + expect(dive.id, '4711'); + expect(dive.dateTime, DateTime(2022, 9, 3, 14, 42)); + expect(dive.durationSeconds, 2808); + expect(dive.maxDepth, 12.0); + expect(dive.samples, isEmpty); + expect(dive.tanks, isEmpty); + }); + + test('throws FormatException when a mandatory field is missing', () { + final json = minimal()..remove('maxdepth'); + expect(() => DivelogsDive.fromJson(json), throwsFormatException); + }); + + test('parses mixed sampledata (bare depths and {d,t} objects)', () { + final dive = DivelogsDive.fromJson({ + ...minimal(), + 'samplerate': 10, + 'sampledata': [ + {'d': 1, 't': 13}, + 10, + {'d': 17, 't': 12}, + 0, + ], + }); + expect(dive.sampleRateSeconds, 10); + expect(dive.samples, hasLength(4)); + expect(dive.samples[0].depth, 1.0); + expect(dive.samples[0].temperature, 13.0); + expect(dive.samples[1].depth, 10.0); + expect(dive.samples[1].temperature, isNull); + }); + + test('parses tanks', () { + final dive = DivelogsDive.fromJson({ + ...minimal(), + 'tanks': [ + { + 'o2': 28, + 'he': 0, + 'start_pressure': 214.56, + 'end_pressure': 103, + 'vol': 12, + 'wp': 200, + 'dbltank': false, + 'tankname': 'Main', + }, + ], + }); + expect(dive.tanks, hasLength(1)); + final tank = dive.tanks.single; + expect(tank.o2, 28.0); + expect(tank.startPressure, 214.56); + expect(tank.endPressure, 103.0); + expect(tank.volume, 12.0); + expect(tank.workingPressure, 200.0); + expect(tank.name, 'Main'); + }); + + test('parses optional metadata fields', () { + final dive = DivelogsDive.fromJson({ + ...minimal(), + 'meandepth': 7.9, + 'buddy': 'Buddy', + 'divesite': 'Shinenead', + 'location': 'Aegypten, Rotes Meer', + 'lat': 24.669683, + 'lng': 35.125225, + 'notes': 'nice dive', + 'weather': 'sunny', + 'visibility': 'good', + 'airtemp': 28, + 'depthtemp': 21, + 'surfacetemp': 26, + 'weights': 4, + 'surface_interval': 3600, + 'dc_model': 'Suunto D6', + }); + expect(dive.meanDepth, 7.9); + expect(dive.buddy, 'Buddy'); + expect(dive.siteName, 'Shinenead'); + expect(dive.latitude, closeTo(24.669683, 1e-9)); + expect(dive.depthTemp, 21.0); + expect(dive.weightsKg, 4.0); + expect(dive.surfaceIntervalSeconds, 3600); + expect(dive.dcModel, 'Suunto D6'); + }); +} From 1674b4203cb3a0bbd9111b84c589f5fdf6f2160a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:57:52 -0400 Subject: [PATCH 07/35] feat: add divelogs.de account adapter with LogbookSyncCapable marker --- lib/core/providers/account_providers.dart | 4 ++ .../accounts/account_provider_adapter.dart | 5 ++ .../adapters/divelogs_account_adapter.dart | 50 +++++++++++++++ .../services/divelogs/divelogs_models.dart | 8 +-- .../divelogs_account_adapter_test.dart | 64 +++++++++++++++++++ 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 lib/core/services/accounts/adapters/divelogs_account_adapter.dart create mode 100644 test/core/services/accounts/adapters/divelogs_account_adapter_test.dart diff --git a/lib/core/providers/account_providers.dart b/lib/core/providers/account_providers.dart index dae2133471..9f5e5a6f10 100644 --- a/lib/core/providers/account_providers.dart +++ b/lib/core/providers/account_providers.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:submersion/core/data/repositories/connected_accounts_repository.dart'; import 'package:submersion/core/services/accounts/account_credentials_store.dart'; import 'package:submersion/core/services/accounts/account_provider_registry.dart'; +import 'package:submersion/core/services/accounts/adapters/divelogs_account_adapter.dart'; import 'package:submersion/core/services/accounts/adapters/dropbox_account_adapter.dart'; import 'package:submersion/core/services/accounts/adapters/google_drive_account_adapter.dart'; import 'package:submersion/core/services/accounts/adapters/icloud_account_adapter.dart'; @@ -35,5 +36,8 @@ final accountProviderRegistryProvider = Provider( GoogleDriveAccountAdapter(), ICloudAccountAdapter(), LightroomAccountAdapter(), + DivelogsAccountAdapter( + credentials: ref.watch(accountCredentialsStoreProvider), + ), ]), ); diff --git a/lib/core/services/accounts/account_provider_adapter.dart b/lib/core/services/accounts/account_provider_adapter.dart index 42031e1e2e..3ecfc44c15 100644 --- a/lib/core/services/accounts/account_provider_adapter.dart +++ b/lib/core/services/accounts/account_provider_adapter.dart @@ -33,3 +33,8 @@ abstract interface class MediaStoreCapable { /// Marker: the account is a media acquisition source (Lightroom now; /// Immich/SMB per the program spec later). abstract interface class MediaSourceCapable {} + +/// Marker: the account syncs with a third-party logbook service +/// (divelogs.de now). Phase 2 of the divelogs program adds sync-plan +/// members. +abstract interface class LogbookSyncCapable {} diff --git a/lib/core/services/accounts/adapters/divelogs_account_adapter.dart b/lib/core/services/accounts/adapters/divelogs_account_adapter.dart new file mode 100644 index 0000000000..9a2d71ff9d --- /dev/null +++ b/lib/core/services/accounts/adapters/divelogs_account_adapter.dart @@ -0,0 +1,50 @@ +import 'package:http/http.dart' as http; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; +import 'package:submersion/core/services/accounts/account_provider_adapter.dart'; +import 'package:submersion/core/services/accounts/connected_account.dart' + as domain; +import 'package:submersion/core/services/divelogs/divelogs_auth_manager.dart'; + +/// Adapter for divelogs.de accounts (connector kind: per-diver logbook +/// sync, no cloud storage). Credentials are a username/password/JWT blob +/// in the keychain under the per-account key. +class DivelogsAccountAdapter extends AccountProviderAdapter + implements LogbookSyncCapable { + DivelogsAccountAdapter({ + required AccountCredentialsStore credentials, + http.Client? httpClient, + }) : _credentials = credentials, + _httpClient = httpClient; + + final AccountCredentialsStore _credentials; + final http.Client? _httpClient; + final Map _managers = {}; + + @override + AccountKind get kind => AccountKind.divelogs; + + DivelogsAuthManager authManagerFor(domain.ConnectedAccount account) => + _managers.putIfAbsent( + account.id, + () => DivelogsAuthManager( + credentials: _credentials, + accountId: account.id, + httpClient: _httpClient, + ), + ); + + @override + Future status(domain.ConnectedAccount account) async { + final blob = await _credentials.read(account.id); + return (blob == null || blob.isEmpty) + ? AccountStatus.needsSignIn + : AccountStatus.signedIn; + } + + @override + Future disconnect(domain.ConnectedAccount account) async { + await authManagerFor(account).disconnect(); + _managers.remove(account.id); + } +} diff --git a/lib/core/services/divelogs/divelogs_models.dart b/lib/core/services/divelogs/divelogs_models.dart index 1de8b7967a..34f11d5e6b 100644 --- a/lib/core/services/divelogs/divelogs_models.dart +++ b/lib/core/services/divelogs/divelogs_models.dart @@ -6,14 +6,14 @@ library; double? _asDouble(Object? v) => switch (v) { - num n => n.toDouble(), - String s => double.tryParse(s), + final num n => n.toDouble(), + final String s => double.tryParse(s), _ => null, }; int? _asInt(Object? v) => switch (v) { - num n => n.toInt(), - String s => int.tryParse(s), + final num n => n.toInt(), + final String s => int.tryParse(s), _ => null, }; diff --git a/test/core/services/accounts/adapters/divelogs_account_adapter_test.dart b/test/core/services/accounts/adapters/divelogs_account_adapter_test.dart new file mode 100644 index 0000000000..f92df9a5a0 --- /dev/null +++ b/test/core/services/accounts/adapters/divelogs_account_adapter_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; +import 'package:submersion/core/services/accounts/account_provider_adapter.dart'; +import 'package:submersion/core/services/accounts/adapters/divelogs_account_adapter.dart'; +import 'package:submersion/core/services/accounts/connected_account.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; + +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + late InMemoryKeychain keychain; + late AccountCredentialsStore store; + late DivelogsAccountAdapter adapter; + + ConnectedAccount account(String id) => ConnectedAccount( + id: id, + kind: AccountKind.divelogs, + label: 'divelogs.de', + accountIdentifier: 'eric', + createdAt: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + updatedAt: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + ); + + setUp(() { + keychain = InMemoryKeychain(); + store = AccountCredentialsStore(storage: keychain); + adapter = DivelogsAccountAdapter(credentials: store); + }); + + test('kind is divelogs and adapter is LogbookSyncCapable', () { + expect(adapter.kind, AccountKind.divelogs); + expect(adapter, isA()); + }); + + test('status is needsSignIn without credentials, signedIn with', () async { + expect(await adapter.status(account('a1')), AccountStatus.needsSignIn); + await store.write( + 'a1', + const DivelogsCredentials(username: 'e', password: 'p').toJsonString(), + ); + expect(await adapter.status(account('a1')), AccountStatus.signedIn); + }); + + test('disconnect deletes only this account credentials', () async { + await store.write( + 'a1', + const DivelogsCredentials(username: 'e', password: 'p').toJsonString(), + ); + await store.write( + 'a2', + const DivelogsCredentials(username: 'f', password: 'q').toJsonString(), + ); + await adapter.disconnect(account('a1')); + expect(await store.read('a1'), isNull); + expect(await store.read('a2'), isNotNull); + }); + + test('authManagerFor caches one manager per account id', () { + final m1 = adapter.authManagerFor(account('a1')); + expect(identical(m1, adapter.authManagerFor(account('a1'))), isTrue); + expect(identical(m1, adapter.authManagerFor(account('a2'))), isFalse); + }); +} From 575365cf624fa7e0f3d2fdf9d29b023be56ac04c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 18:58:59 -0400 Subject: [PATCH 08/35] feat: map divelogs.de dives into universal import entity maps --- .../data/services/divelogs_dive_mapper.dart | 113 ++++++++++++++++ .../services/divelogs_dive_mapper_test.dart | 127 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 lib/features/universal_import/data/services/divelogs_dive_mapper.dart create mode 100644 test/features/universal_import/data/services/divelogs_dive_mapper_test.dart diff --git a/lib/features/universal_import/data/services/divelogs_dive_mapper.dart b/lib/features/universal_import/data/services/divelogs_dive_mapper.dart new file mode 100644 index 0000000000..99b48cd482 --- /dev/null +++ b/lib/features/universal_import/data/services/divelogs_dive_mapper.dart @@ -0,0 +1,113 @@ +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +/// Converts divelogs.de API dives into the untyped entity maps consumed by +/// the universal import pipeline (UddfEntityImporter key conventions). +/// +/// divelogs.de uses 0 for "not set" on numeric optionals (temps, weights); +/// those are dropped rather than imported as literal zeros. +class DivelogsDiveMapper { + const DivelogsDiveMapper(); + + static String siteKey(String name) => + 'divelogs-site-${name.trim().toLowerCase()}'; + + Map mapDive(DivelogsDive dive) { + final map = { + 'dateTime': dive.dateTime, + 'runtime': Duration(seconds: dive.durationSeconds), + 'maxDepth': dive.maxDepth, + if (dive.meanDepth != null && dive.meanDepth! > 0) + 'avgDepth': dive.meanDepth, + 'notes': _buildNotes(dive), + }; + + final waterTemp = _positive(dive.depthTemp) ?? _positive(dive.surfaceTemp); + if (waterTemp != null) map['waterTemp'] = waterTemp; + final airTemp = _positive(dive.airTemp); + if (airTemp != null) map['airTemp'] = airTemp; + final weight = _positive(dive.weightsKg); + if (weight != null) map['weightUsed'] = weight; + + if (dive.buddy != null) { + map['buddy'] = dive.buddy; + map['buddyRefs'] = [dive.buddy!]; + } + if (dive.latitude != null && dive.longitude != null) { + map['latitude'] = dive.latitude; + map['longitude'] = dive.longitude; + } + if (dive.dcModel != null) map['diveComputerModel'] = dive.dcModel; + if (dive.surfaceIntervalSeconds != null && + dive.surfaceIntervalSeconds! > 0) { + map['surfaceInterval'] = Duration(seconds: dive.surfaceIntervalSeconds!); + } + if (dive.id != null) map['sourceUuid'] = 'divelogs:${dive.id}'; + + final siteName = dive.siteName; + if (siteName != null) { + map['siteName'] = siteName; + map['site'] = { + 'uddfId': siteKey(siteName), + 'name': siteName, + }; + } + + final tanks = dive.tanks + .map( + (t) => { + 'gasMix': GasMix(o2: t.o2 ?? 21.0, he: t.he ?? 0.0), + if (t.startPressure != null) 'startPressure': t.startPressure, + if (t.endPressure != null) 'endPressure': t.endPressure, + if (t.volume != null && t.volume! > 0) + 'volume': t.volume!.toDouble(), + if (t.workingPressure != null && t.workingPressure! > 0) + 'workingPressure': t.workingPressure, + if (t.name != null) 'name': t.name, + }, + ) + .toList(); + if (tanks.isNotEmpty) map['tanks'] = tanks; + + final rate = dive.sampleRateSeconds; + if (dive.samples.isNotEmpty && rate != null && rate > 0) { + map['profile'] = [ + for (var i = 0; i < dive.samples.length; i++) + { + 'timestamp': i * rate, + 'depth': dive.samples[i].depth, + if (dive.samples[i].temperature != null) + 'temperature': dive.samples[i].temperature, + }, + ]; + } + + return map; + } + + /// Site entity map for the payload, or null when the dive has no site name. + Map? mapSite(DivelogsDive dive) { + final name = dive.siteName; + if (name == null) return null; + return { + 'uddfId': siteKey(name), + 'name': name, + if (dive.latitude != null) 'latitude': dive.latitude, + if (dive.longitude != null) 'longitude': dive.longitude, + }; + } + + double? _positive(double? value) => + (value != null && value > 0) ? value : null; + + String _buildNotes(DivelogsDive dive) { + final parts = [ + if (dive.notes != null) dive.notes!, + if (dive.weather != null) 'Weather: ${dive.weather}', + if (dive.visibility != null) 'Visibility: ${dive.visibility}', + if (dive.boat != null) 'Boat: ${dive.boat}', + if (dive.location != null) 'Location: ${dive.location}', + ]; + return parts.join('\n'); + } +} diff --git a/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart b/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart new file mode 100644 index 0000000000..5da1453a59 --- /dev/null +++ b/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/services/divelogs_dive_mapper.dart'; + +void main() { + const mapper = DivelogsDiveMapper(); + + DivelogsDive dive({ + String? id = '4711', + String? siteName = 'Shinenead', + double? lat = 24.6, + double? lng = 35.1, + }) => DivelogsDive( + id: id, + dateTime: DateTime(2022, 9, 3, 14, 42), + durationSeconds: 2808, + maxDepth: 12, + meanDepth: 7.9, + sampleRateSeconds: 10, + samples: const [ + DivelogsSample(depth: 1, temperature: 13), + DivelogsSample(depth: 10), + ], + tanks: const [ + DivelogsTank( + o2: 28, + he: 0, + startPressure: 214.56, + endPressure: 103, + volume: 12, + workingPressure: 200, + ), + ], + buddy: 'Buddy', + siteName: siteName, + location: 'Aegypten, Rotes Meer', + notes: 'nice dive', + weather: 'sunny', + visibility: 'good', + dcModel: 'Suunto D6', + latitude: lat, + longitude: lng, + airTemp: 28, + depthTemp: 21, + surfaceTemp: 26, + weightsKg: 4, + surfaceIntervalSeconds: 3600, + ); + + test('maps core fields with importer-compatible keys', () { + final map = mapper.mapDive(dive()); + expect(map['dateTime'], DateTime(2022, 9, 3, 14, 42)); + expect(map['runtime'], const Duration(seconds: 2808)); + expect(map['maxDepth'], 12.0); + expect(map['avgDepth'], 7.9); + expect(map['waterTemp'], 21.0); // depthtemp wins over surfacetemp + expect(map['airTemp'], 28.0); + expect(map['buddy'], 'Buddy'); + expect(map['buddyRefs'], ['Buddy']); + expect(map['weightUsed'], 4.0); + expect(map['latitude'], 24.6); + expect(map['longitude'], 35.1); + expect(map['diveComputerModel'], 'Suunto D6'); + expect(map['surfaceInterval'], const Duration(seconds: 3600)); + expect(map['sourceUuid'], 'divelogs:4711'); + }); + + test('appends weather, visibility, and location to notes', () { + final notes = mapper.mapDive(dive())['notes'] as String; + expect(notes, contains('nice dive')); + expect(notes, contains('Weather: sunny')); + expect(notes, contains('Visibility: good')); + expect(notes, contains('Location: Aegypten, Rotes Meer')); + }); + + test('builds profile from samples using samplerate', () { + final profile = mapper.mapDive(dive())['profile'] as List; + expect(profile, hasLength(2)); + expect(profile[0], {'timestamp': 0, 'depth': 1.0, 'temperature': 13.0}); + expect((profile[1] as Map)['timestamp'], 10); + expect((profile[1] as Map).containsKey('temperature'), isFalse); + }); + + test('builds tank maps with GasMix and double volume', () { + final tanks = mapper.mapDive(dive())['tanks'] as List; + final tank = tanks.single as Map; + expect((tank['gasMix'] as GasMix).o2, 28.0); + expect(tank['startPressure'], 214.56); + expect(tank['endPressure'], 103.0); + expect(tank['volume'], isA()); + expect(tank['workingPressure'], 200.0); + }); + + test('links dive to site entity via uddfId and mapSite emits site map', () { + final d = dive(); + final map = mapper.mapDive(d); + final site = mapper.mapSite(d)!; + expect((map['site'] as Map)['uddfId'], site['uddfId']); + expect(site['name'], 'Shinenead'); + expect(site['latitude'], 24.6); + expect(site['longitude'], 35.1); + }); + + test('no sourceUuid key when remote id missing', () { + expect(mapper.mapDive(dive(id: null)).containsKey('sourceUuid'), isFalse); + }); + + test('no site when name missing', () { + final d = dive(siteName: null); + expect(mapper.mapSite(d), isNull); + expect(mapper.mapDive(d).containsKey('site'), isFalse); + }); + + test('zero weights and temps are treated as unset', () { + final d = DivelogsDive( + dateTime: DateTime(2022), + durationSeconds: 60, + maxDepth: 5, + weightsKg: 0, + airTemp: 0, + ); + final map = mapper.mapDive(d); + expect(map.containsKey('weightUsed'), isFalse); + expect(map.containsKey('airTemp'), isFalse); + }); +} From 3b25727b89bce82b473b60cd77cfe5a79084e75a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 19:00:57 -0400 Subject: [PATCH 09/35] feat: assemble divelogs.de import payload with dedup-ready source uuids --- .../services/divelogs_import_service.dart | 54 ++++++++ .../divelogs_import_service_test.dart | 124 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 lib/features/universal_import/data/services/divelogs_import_service.dart create mode 100644 test/features/universal_import/data/services/divelogs_import_service_test.dart diff --git a/lib/features/universal_import/data/services/divelogs_import_service.dart b/lib/features/universal_import/data/services/divelogs_import_service.dart new file mode 100644 index 0000000000..19f637ed28 --- /dev/null +++ b/lib/features/universal_import/data/services/divelogs_import_service.dart @@ -0,0 +1,54 @@ +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/divelogs_dive_mapper.dart'; + +/// Fetches the full divelogs.de logbook and assembles an ImportPayload for +/// the universal import pipeline. +class DivelogsImportService { + DivelogsImportService({ + required DivelogsApiClient api, + DivelogsDiveMapper mapper = const DivelogsDiveMapper(), + }) : _api = api, + _mapper = mapper; + + final DivelogsApiClient _api; + final DivelogsDiveMapper _mapper; + + Future fetchAllDives() async { + final result = await _api.getAllDives(); + + final diveEntities = >[]; + final sitesByKey = >{}; + for (final dive in result.dives) { + diveEntities.add(_mapper.mapDive(dive)); + final site = _mapper.mapSite(dive); + if (site != null) { + sitesByKey.putIfAbsent(site['uddfId'] as String, () => site); + } + } + + final entities = >>{}; + if (diveEntities.isNotEmpty) { + entities[ImportEntityType.dives] = diveEntities; + } + if (sitesByKey.isNotEmpty) { + entities[ImportEntityType.sites] = sitesByKey.values.toList(); + } + + return ImportPayload( + entities: entities, + warnings: [ + if (result.skippedCount > 0) + ImportWarning( + severity: ImportWarningSeverity.warning, + message: + '${result.skippedCount} dives could not be read from ' + 'divelogs.de and were skipped.', + ), + ], + metadata: {'source': 'divelogs.de', 'diveCount': result.dives.length}, + ); + } +} diff --git a/test/features/universal_import/data/services/divelogs_import_service_test.dart b/test/features/universal_import/data/services/divelogs_import_service_test.dart new file mode 100644 index 0000000000..742986a56d --- /dev/null +++ b/test/features/universal_import/data/services/divelogs_import_service_test.dart @@ -0,0 +1,124 @@ +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/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/divelogs_import_service.dart'; +import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; + +void main() { + Map diveJson( + int id, { + String date = '2022-09-03', + String time = '14:42:00', + }) => { + 'id': id, + 'date': date, + 'time': time, + 'duration': 2808, + 'maxdepth': 12, + 'divesite': 'Shinenead', + 'lat': 24.6, + 'lng': 35.1, + }; + + DivelogsImportService service(Object body) => DivelogsImportService( + api: DivelogsApiClient( + getBearerToken: () async => 't', + onTokenRejected: () {}, + httpClient: MockClient( + (req) async => http.Response(jsonEncode(body), 200), + ), + ), + ); + + test('assembles payload with dives and deduped sites', () async { + final payload = await service([ + diveJson(1), + diveJson(2, time: '18:00:00'), + ]).fetchAllDives(); + + expect(payload.entitiesOf(ImportEntityType.dives), hasLength(2)); + expect(payload.entitiesOf(ImportEntityType.sites), hasLength(1)); + expect( + payload.entitiesOf(ImportEntityType.dives).first['sourceUuid'], + 'divelogs:1', + ); + expect(payload.metadata['source'], 'divelogs.de'); + expect(payload.metadata['diveCount'], 2); + expect(payload.warnings, isEmpty); + }); + + test('surfaces skipped dives as a warning', () async { + final payload = await service([ + diveJson(1), + {'date': '2022-01-01'}, + ]).fetchAllDives(); + + expect(payload.entitiesOf(ImportEntityType.dives), hasLength(1)); + expect(payload.warnings, hasLength(1)); + expect(payload.warnings.single.severity, ImportWarningSeverity.warning); + expect(payload.warnings.single.message, contains('1 dives')); + }); + + group('duplicate checker integration', () { + final existingDive = Dive( + id: 'existing-1', + dateTime: DateTime(2022, 9, 3, 14, 42), + entryTime: DateTime(2022, 9, 3, 14, 42), + runtime: const Duration(seconds: 2808), + maxDepth: 12, + ); + + ImportDuplicateResult check( + payload, { + Map existingSourceUuidByDiveId = const {}, + }) => const ImportDuplicateChecker().check( + payload: payload, + existingDives: [existingDive], + existingSites: const [], + existingTrips: const [], + existingEquipment: const [], + existingBuddies: const [], + existingDiveCenters: const [], + existingCertifications: const [], + existingTags: const [], + existingDiveTypes: const [], + existingSourceUuidByDiveId: existingSourceUuidByDiveId, + ); + + test('fuzzy date/time match flags the pulled dive as duplicate', () async { + final payload = await service([ + diveJson(1), + diveJson(2, date: '2023-05-05'), + ]).fetchAllDives(); + + final result = check(payload); + final match = result.diveMatches[0]; + expect(match, isNotNull); + expect(match!.diveId, 'existing-1'); + expect(match.score, greaterThanOrEqualTo(0.7)); + expect(match.matchedExistingSource, isFalse); + expect( + result.diveMatches[1], + isNull, + reason: 'the 2023 dive matches nothing', + ); + }); + + test('second pull is a Pass-0 exact source match', () async { + final payload = await service([diveJson(1)]).fetchAllDives(); + + final result = check( + payload, + existingSourceUuidByDiveId: {'existing-1': 'divelogs:1'}, + ); + expect(result.diveMatches[0]?.matchedExistingSource, isTrue); + expect(result.diveMatches[0]?.score, 1.0); + }); + }); +} From ef0a0a35e77496ffe571b74044a64a579bdfb233 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 19:09:57 -0400 Subject: [PATCH 10/35] feat: add divelogs.de import wizard adapter with sign-in and fetch step --- .../data/adapters/divelogs_adapter.dart | 41 +++ .../data/adapters/universal_adapter.dart | 14 +- .../domain/models/import_bundle.dart | 3 + .../widgets/divelogs_fetch_step.dart | 303 ++++++++++++++++++ .../providers/universal_import_providers.dart | 16 + .../domain/models/import_bundle_test.dart | 3 +- .../widgets/divelogs_fetch_step_test.dart | 139 ++++++++ 7 files changed, 508 insertions(+), 11 deletions(-) create mode 100644 lib/features/import_wizard/data/adapters/divelogs_adapter.dart create mode 100644 lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart create mode 100644 test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart diff --git a/lib/features/import_wizard/data/adapters/divelogs_adapter.dart b/lib/features/import_wizard/data/adapters/divelogs_adapter.dart new file mode 100644 index 0000000000..177d56fb68 --- /dev/null +++ b/lib/features/import_wizard/data/adapters/divelogs_adapter.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; +import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; +import 'package:submersion/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; + +/// True once the divelogs.de fetch has installed a payload into the +/// universal import notifier; gates the wizard's auto-advance to review. +final divelogsPayloadReadyProvider = Provider( + (ref) => ref.watch(universalImportNotifierProvider).payload != null, +); + +/// HTTP client for divelogs.de calls; null means the real network client. +/// Overridable so widget tests can supply a MockClient. +final divelogsHttpClientProvider = Provider((ref) => null); + +/// Import source that pulls the user's logbook from divelogs.de. +/// +/// Reuses the entire universal pipeline (bundle building, duplicate check, +/// commit); only acquisition differs: sign in and fetch instead of a file. +class DivelogsImportAdapter extends UniversalAdapter { + DivelogsImportAdapter({required super.ref}) + : super(displayName: 'divelogs.de'); + + @override + ImportSourceType get sourceType => ImportSourceType.divelogs; + + @override + List get acquisitionSteps => [ + WizardStepDef( + label: 'Sign In', + icon: Icons.travel_explore_outlined, + builder: (context) => const DivelogsFetchStep(), + canAdvance: divelogsPayloadReadyProvider, + autoAdvance: true, + ), + ]; +} diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index 4bb7dd9fdd..80d10c1afd 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -194,12 +194,9 @@ class UniversalAdapter implements ImportSourceAdapter { final payload = notifierState.payload; if (payload == null) { - return const ImportBundle( - source: ImportSourceInfo( - type: ImportSourceType.universal, - displayName: 'File Import', - ), - groups: {}, + return ImportBundle( + source: ImportSourceInfo(type: sourceType, displayName: displayName), + groups: const {}, ); } @@ -272,10 +269,7 @@ class UniversalAdapter implements ImportSourceAdapter { ); return ImportBundle( - source: ImportSourceInfo( - type: ImportSourceType.universal, - displayName: _displayName, - ), + source: ImportSourceInfo(type: sourceType, displayName: displayName), groups: groups, ); } diff --git a/lib/features/import_wizard/domain/models/import_bundle.dart b/lib/features/import_wizard/domain/models/import_bundle.dart index 7b857fe31a..93564df2b1 100644 --- a/lib/features/import_wizard/domain/models/import_bundle.dart +++ b/lib/features/import_wizard/domain/models/import_bundle.dart @@ -19,6 +19,9 @@ enum ImportSourceType { /// A dive computer download. diveComputer, + + /// A divelogs.de account pull. + divelogs, } /// The kind of entity represented by an [EntityGroup]. diff --git a/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart new file mode 100644 index 0000000000..6e1a64dca4 --- /dev/null +++ b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:submersion/core/providers/account_providers.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; +import 'package:submersion/core/services/accounts/account_provider_adapter.dart'; +import 'package:submersion/core/services/accounts/adapters/divelogs_account_adapter.dart'; +import 'package:submersion/core/services/accounts/connected_account.dart'; +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/core/services/divelogs/divelogs_auth_manager.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; +import 'package:submersion/features/universal_import/data/services/divelogs_import_service.dart'; +import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; + +enum _StepPhase { loading, signIn, fetching, wrongDiver, error, done } + +/// Acquisition step for the divelogs.de import source: signs the user in +/// (creating the connected account on first use) and fetches the full +/// logbook into the universal import notifier. +class DivelogsFetchStep extends ConsumerStatefulWidget { + const DivelogsFetchStep({super.key}); + + @override + ConsumerState createState() => _DivelogsFetchStepState(); +} + +class _DivelogsFetchStepState extends ConsumerState { + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + _StepPhase _phase = _StepPhase.loading; + String? _errorMessage; + String? _selectedDiverId; + ConnectedAccount? _account; + bool _connecting = false; + + @override + void initState() { + super.initState(); + Future.microtask(_init); + } + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _init() async { + final repo = ref.read(connectedAccountsRepositoryProvider); + final account = await repo.getByKind(AccountKind.divelogs); + if (!mounted) return; + if (account == null) { + final current = await ref.read(currentDiverProvider.future); + if (!mounted) return; + setState(() { + _selectedDiverId = current?.id; + _phase = _StepPhase.signIn; + }); + return; + } + _account = account; + final adapter = _adapter; + final status = await adapter.status(account); + if (!mounted) return; + if (status != AccountStatus.signedIn) { + _usernameController.text = account.accountIdentifier ?? ''; + setState(() { + _selectedDiverId = account.diverId; + _phase = _StepPhase.signIn; + }); + return; + } + await _fetch(account); + } + + DivelogsAccountAdapter get _adapter => + ref.read(accountProviderRegistryProvider).adapterFor(AccountKind.divelogs) + as DivelogsAccountAdapter; + + Future _connect() async { + final username = _usernameController.text.trim(); + final password = _passwordController.text; + if (username.isEmpty || password.isEmpty) return; + setState(() { + _connecting = true; + _errorMessage = null; + }); + try { + final token = await DivelogsAuthManager.login( + username: username, + password: password, + httpClient: ref.read(divelogsHttpClientProvider), + ); + final repo = ref.read(connectedAccountsRepositoryProvider); + final account = + _account ?? + await repo.create( + kind: AccountKind.divelogs, + label: 'divelogs.de', + accountIdentifier: username, + diverId: _selectedDiverId, + ); + await ref + .read(accountCredentialsStoreProvider) + .write( + account.id, + DivelogsCredentials( + username: username, + password: password, + bearerToken: token, + ).toJsonString(), + ); + if (!mounted) return; + _account = account; + await _fetch(account); + } on DivelogsAuthException catch (e) { + if (!mounted) return; + setState(() { + _connecting = false; + _errorMessage = e.message; + }); + } finally { + if (mounted && _connecting) { + setState(() => _connecting = false); + } + } + } + + Future _fetch(ConnectedAccount account) async { + final currentDiver = await ref.read(currentDiverProvider.future); + if (!mounted) return; + if (account.diverId != null && + currentDiver != null && + account.diverId != currentDiver.id) { + setState(() => _phase = _StepPhase.wrongDiver); + return; + } + setState(() => _phase = _StepPhase.fetching); + try { + final manager = _adapter.authManagerFor(account); + final api = DivelogsApiClient( + getBearerToken: manager.getToken, + onTokenRejected: manager.invalidateToken, + httpClient: ref.read(divelogsHttpClientProvider), + ); + final payload = await DivelogsImportService(api: api).fetchAllDives(); + if (!mounted) return; + await ref + .read(universalImportNotifierProvider.notifier) + .setExternalPayload(payload); + if (!mounted) return; + setState(() => _phase = _StepPhase.done); + } on DivelogsApiException catch (e) { + if (!mounted) return; + setState(() { + _phase = _StepPhase.error; + _errorMessage = e.message; + }); + } on DivelogsAuthException catch (e) { + if (!mounted) return; + setState(() { + _phase = _StepPhase.error; + _errorMessage = e.message; + }); + } + } + + @override + Widget build(BuildContext context) { + return switch (_phase) { + _StepPhase.loading => const Center(child: CircularProgressIndicator()), + _StepPhase.signIn => _buildSignInForm(context), + _StepPhase.fetching => _buildProgress(context), + _StepPhase.done => _buildMessage(context, 'Dives fetched.'), + _StepPhase.wrongDiver => _buildMessage( + context, + 'This divelogs.de account is linked to a different diver profile. ' + 'Switch divers to import.', + ), + _StepPhase.error => _buildError(context), + }; + } + + Widget _buildSignInForm(BuildContext context) { + final divers = ref.watch(allDiversProvider); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Sign in to divelogs.de', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _usernameController, + decoration: const InputDecoration(labelText: 'Username'), + autocorrect: false, + enabled: !_connecting, + ), + const SizedBox(height: 12), + TextField( + controller: _passwordController, + decoration: const InputDecoration(labelText: 'Password'), + obscureText: true, + enabled: !_connecting, + onSubmitted: (_) => _connect(), + ), + const SizedBox(height: 12), + divers.when( + data: (list) => DropdownButtonFormField( + initialValue: _selectedDiverId, + decoration: const InputDecoration(labelText: 'Import into diver'), + items: [ + for (final diver in list) + DropdownMenuItem(value: diver.id, child: Text(diver.name)), + ], + onChanged: _connecting || _account != null + ? null + : (value) => setState(() => _selectedDiverId = value), + ), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + _errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 20), + FilledButton( + onPressed: _connecting ? null : _connect, + child: _connecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Connect'), + ), + ], + ), + ); + } + + Widget _buildProgress(BuildContext context) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Fetching dives from divelogs.de...'), + ], + ), + ); + } + + Widget _buildMessage(BuildContext context, String message) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(message, textAlign: TextAlign.center), + ), + ); + } + + Widget _buildError(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _errorMessage ?? 'Could not fetch dives from divelogs.de.', + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + final account = _account; + if (account != null) { + _fetch(account); + } else { + setState(() => _phase = _StepPhase.signIn); + } + }, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } +} 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 1704779e4a..2e48577c54 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -675,6 +675,22 @@ class UniversalImportNotifier extends StateNotifier { ); } + /// Installs a payload produced outside the file-parse path (e.g. a REST + /// source like divelogs.de) and runs the standard duplicate check and + /// default-selection pass so the wizard can proceed to review. + Future setExternalPayload(ImportPayload payload) async { + state = state.copyWith(isLoading: true, clearError: true); + final dupResult = await _checkDuplicates(payload); + final selections = _defaultSelections(payload, dupResult); + state = state.copyWith( + isLoading: false, + payload: payload, + duplicateResult: dupResult, + selections: selections, + currentStep: ImportWizardStep.review, + ); + } + // -- Parsing + Duplicate Check -- Future _parseAndCheckDuplicates() async { 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..cdcf3277a6 100644 --- a/test/features/import_wizard/domain/models/import_bundle_test.dart +++ b/test/features/import_wizard/domain/models/import_bundle_test.dart @@ -7,12 +7,13 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da void main() { group('ImportSourceType', () { test('has all expected values', () { - expect(ImportSourceType.values, hasLength(5)); + expect(ImportSourceType.values, hasLength(6)); expect(ImportSourceType.values, contains(ImportSourceType.uddf)); expect(ImportSourceType.values, contains(ImportSourceType.fit)); expect(ImportSourceType.values, contains(ImportSourceType.healthKit)); expect(ImportSourceType.values, contains(ImportSourceType.universal)); expect(ImportSourceType.values, contains(ImportSourceType.diveComputer)); + expect(ImportSourceType.values, contains(ImportSourceType.divelogs)); }); }); diff --git a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart new file mode 100644 index 0000000000..962f164856 --- /dev/null +++ b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/providers/account_providers.dart'; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; +import 'package:submersion/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../helpers/test_database.dart'; +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + final diver = Diver( + id: 'diver-1', + name: 'Eric', + createdAt: DateTime(2020), + updatedAt: DateTime(2020), + ); + + late InMemoryKeychain keychain; + late AccountCredentialsStore credentialsStore; + late SharedPreferences prefs; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + keychain = InMemoryKeychain(); + credentialsStore = AccountCredentialsStore(storage: keychain); + }); + + tearDown(() => tearDownTestDatabase()); + + Widget host(http.Client mockClient) => ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + accountCredentialsStoreProvider.overrideWithValue(credentialsStore), + divelogsHttpClientProvider.overrideWithValue(mockClient), + allDiversProvider.overrideWith((ref) async => [diver]), + currentDiverProvider.overrideWith((ref) async => diver), + ], + child: const MaterialApp( + themeAnimationDuration: Duration.zero, + home: Scaffold(body: DivelogsFetchStep()), + ), + ); + + MockClient loginThenDives({int loginStatus = 200}) => MockClient((req) async { + if (req.url.path == '/api/login') { + return http.Response( + loginStatus == 200 ? jsonEncode({'bearer_token': 'jwt'}) : '', + loginStatus, + ); + } + if (req.url.path == '/api/dives') { + return http.Response(jsonEncode([]), 200); + } + fail('unexpected request ${req.url}'); + }); + + testWidgets('shows sign-in form when no account exists', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(host(loginThenDives())); + await tester.pumpAndSettle(); + }); + + expect(find.text('Sign in to divelogs.de'), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + }); + + testWidgets('failed login shows error and creates no account', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(host(loginThenDives(loginStatus: 401))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'eric'); + await tester.enterText(find.byType(TextField).at(1), 'bad'); + await tester.ensureVisible(find.text('Connect')); + await tester.tap(find.text('Connect')); + // Real async work (HTTP mock + DB) resolves inside runAsync. + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); + }); + + expect( + find.textContaining('rejected the username or password'), + findsOneWidget, + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(DivelogsFetchStep)), + ); + final repo = container.read(connectedAccountsRepositoryProvider); + expect(await repo.getByKind(AccountKind.divelogs), isNull); + }); + + testWidgets( + 'successful login creates diver-bound account and stores credentials', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(host(loginThenDives())); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'eric'); + await tester.enterText(find.byType(TextField).at(1), 'secret'); + await tester.ensureVisible(find.text('Connect')); + await tester.tap(find.text('Connect')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(DivelogsFetchStep)), + ); + final repo = container.read(connectedAccountsRepositoryProvider); + final account = await repo.getByKind(AccountKind.divelogs); + expect(account, isNotNull); + expect(account!.diverId, 'diver-1'); + expect(account.accountIdentifier, 'eric'); + + final blob = DivelogsCredentials.fromJsonString( + await credentialsStore.read(account.id), + ); + expect(blob?.username, 'eric'); + expect(blob?.bearerToken, 'jwt'); + }); + }, + ); +} From fb127702bd98bef7889680b9d7615565454b3917 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 19:15:46 -0400 Subject: [PATCH 11/35] feat: add divelogs.de import entry point, route, and translations --- lib/core/router/app_router.dart | 16 +++++ .../widgets/divelogs_fetch_step.dart | 37 ++++++---- .../presentation/pages/transfer_page.dart | 56 +++++++++++++++ lib/l10n/arb/app_ar.arb | 12 ++++ lib/l10n/arb/app_de.arb | 12 ++++ lib/l10n/arb/app_en.arb | 12 ++++ lib/l10n/arb/app_es.arb | 12 ++++ lib/l10n/arb/app_fr.arb | 12 ++++ lib/l10n/arb/app_he.arb | 12 ++++ lib/l10n/arb/app_hu.arb | 12 ++++ lib/l10n/arb/app_it.arb | 12 ++++ lib/l10n/arb/app_localizations.dart | 72 +++++++++++++++++++ lib/l10n/arb/app_localizations_ar.dart | 38 ++++++++++ lib/l10n/arb/app_localizations_de.dart | 40 +++++++++++ lib/l10n/arb/app_localizations_en.dart | 38 ++++++++++ lib/l10n/arb/app_localizations_es.dart | 40 +++++++++++ lib/l10n/arb/app_localizations_fr.dart | 40 +++++++++++ lib/l10n/arb/app_localizations_he.dart | 38 ++++++++++ lib/l10n/arb/app_localizations_hu.dart | 40 +++++++++++ lib/l10n/arb/app_localizations_it.dart | 40 +++++++++++ lib/l10n/arb/app_localizations_nl.dart | 38 ++++++++++ lib/l10n/arb/app_localizations_pt.dart | 40 +++++++++++ lib/l10n/arb/app_localizations_zh.dart | 37 ++++++++++ lib/l10n/arb/app_nl.arb | 12 ++++ lib/l10n/arb/app_pt.arb | 12 ++++ lib/l10n/arb/app_zh.arb | 12 ++++ .../widgets/divelogs_fetch_step_test.dart | 3 + 27 files changed, 731 insertions(+), 14 deletions(-) diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e68161ab4d..3621dfab30 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -133,6 +133,7 @@ import 'package:submersion/features/dashboard/presentation/pages/dashboard_page. import 'package:submersion/features/planner/presentation/pages/plan_canvas_page.dart'; import 'package:submersion/features/planner/presentation/pages/plan_compare_page.dart'; import 'package:submersion/features/surface_interval_tool/presentation/pages/surface_interval_tool_page.dart'; +import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; import 'package:submersion/l10n/l10n_extension.dart'; import 'package:submersion/shared/widgets/main_scaffold.dart'; @@ -786,6 +787,11 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const _UniversalImportWizardRoute(), ), + GoRoute( + path: 'divelogs-import', + name: 'divelogsImport', + builder: (context, state) => const _DivelogsImportWizardRoute(), + ), ], ), @@ -1347,6 +1353,16 @@ class _UniversalImportWizardRoute extends ConsumerWidget { } } +/// Wrapper that creates a [DivelogsImportAdapter] with Ref from Riverpod. +class _DivelogsImportWizardRoute extends ConsumerWidget { + const _DivelogsImportWizardRoute(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return UnifiedImportWizard(adapter: DivelogsImportAdapter(ref: ref)); + } +} + /// Parses the `forceFull` URL query parameter for the DC download route. /// /// Strict equality against `'true'` — any other value (null, empty, `'1'`, diff --git a/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart index 6e1a64dca4..9e25ba3bdf 100644 --- a/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart +++ b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart @@ -12,6 +12,7 @@ import 'package:submersion/features/divers/presentation/providers/diver_provider import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; import 'package:submersion/features/universal_import/data/services/divelogs_import_service.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; enum _StepPhase { loading, signIn, fetching, wrongDiver, error, done } @@ -174,11 +175,13 @@ class _DivelogsFetchStepState extends ConsumerState { _StepPhase.loading => const Center(child: CircularProgressIndicator()), _StepPhase.signIn => _buildSignInForm(context), _StepPhase.fetching => _buildProgress(context), - _StepPhase.done => _buildMessage(context, 'Dives fetched.'), + _StepPhase.done => _buildMessage( + context, + context.l10n.divelogs_fetch_done, + ), _StepPhase.wrongDiver => _buildMessage( context, - 'This divelogs.de account is linked to a different diver profile. ' - 'Switch divers to import.', + context.l10n.divelogs_fetch_wrongDiver, ), _StepPhase.error => _buildError(context), }; @@ -192,20 +195,24 @@ class _DivelogsFetchStepState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Sign in to divelogs.de', + context.l10n.divelogs_signIn_title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), TextField( controller: _usernameController, - decoration: const InputDecoration(labelText: 'Username'), + decoration: InputDecoration( + labelText: context.l10n.divelogs_signIn_username, + ), autocorrect: false, enabled: !_connecting, ), const SizedBox(height: 12), TextField( controller: _passwordController, - decoration: const InputDecoration(labelText: 'Password'), + decoration: InputDecoration( + labelText: context.l10n.divelogs_signIn_password, + ), obscureText: true, enabled: !_connecting, onSubmitted: (_) => _connect(), @@ -214,7 +221,9 @@ class _DivelogsFetchStepState extends ConsumerState { divers.when( data: (list) => DropdownButtonFormField( initialValue: _selectedDiverId, - decoration: const InputDecoration(labelText: 'Import into diver'), + decoration: InputDecoration( + labelText: context.l10n.divelogs_signIn_diver, + ), items: [ for (final diver in list) DropdownMenuItem(value: diver.id, child: Text(diver.name)), @@ -242,7 +251,7 @@ class _DivelogsFetchStepState extends ConsumerState { height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Connect'), + : Text(context.l10n.divelogs_signIn_connect), ), ], ), @@ -250,13 +259,13 @@ class _DivelogsFetchStepState extends ConsumerState { } Widget _buildProgress(BuildContext context) { - return const Center( + return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Fetching dives from divelogs.de...'), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(context.l10n.divelogs_fetch_inProgress), ], ), ); @@ -279,7 +288,7 @@ class _DivelogsFetchStepState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - _errorMessage ?? 'Could not fetch dives from divelogs.de.', + _errorMessage ?? context.l10n.divelogs_fetch_error, textAlign: TextAlign.center, style: TextStyle(color: Theme.of(context).colorScheme.error), ), @@ -293,7 +302,7 @@ class _DivelogsFetchStepState extends ConsumerState { setState(() => _phase = _StepPhase.signIn); } }, - child: const Text('Retry'), + child: Text(context.l10n.divelogs_fetch_retry), ), ], ), diff --git a/lib/features/transfer/presentation/pages/transfer_page.dart b/lib/features/transfer/presentation/pages/transfer_page.dart index 04c3ecbb28..0cda8fdbbf 100644 --- a/lib/features/transfer/presentation/pages/transfer_page.dart +++ b/lib/features/transfer/presentation/pages/transfer_page.dart @@ -273,6 +273,62 @@ class _ImportSectionContent extends ConsumerWidget { ), ), ), + const SizedBox(height: 12), + // divelogs.de account pull + Card( + clipBehavior: Clip.antiAlias, + child: Semantics( + button: true, + label: context.l10n.transfer_import_divelogs_title, + child: InkWell( + onTap: () => context.push('/transfer/divelogs-import'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.travel_explore_outlined, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.transfer_import_divelogs_title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + context.l10n.transfer_import_divelogs_subtitle, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), const SizedBox(height: 16), _buildInfoCard( context, diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index c97991950b..e87ef3cf53 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "تسجيل الدخول إلى divelogs.de", + "divelogs_signIn_username": "اسم المستخدم", + "divelogs_signIn_password": "كلمة المرور", + "divelogs_signIn_diver": "الاستيراد إلى الغواص", + "divelogs_signIn_connect": "اتصال", + "divelogs_fetch_inProgress": "جارٍ جلب الغطسات من divelogs.de...", + "divelogs_fetch_done": "تم جلب الغطسات.", + "divelogs_fetch_retry": "إعادة المحاولة", + "divelogs_fetch_error": "تعذر جلب الغطسات من divelogs.de.", + "divelogs_fetch_wrongDiver": "حساب divelogs.de هذا مرتبط بملف غواص آخر. بدّل الغواص للاستيراد.", + "transfer_import_divelogs_title": "استيراد من divelogs.de", + "transfer_import_divelogs_subtitle": "اجلب سجل غطساتك من حسابك على divelogs.de", "diveLog_edit_geofenceSuggestion_near": "بالقرب من {location}", "diveLog_edit_geofenceSuggestion_title": "اقتراح المعدات", "diveLog_edit_geofenceSuggestion_body": "تطبيق مجموعة \"{setName}\"؟", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index c17e488123..e0b5c85e63 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Bei divelogs.de anmelden", + "divelogs_signIn_username": "Benutzername", + "divelogs_signIn_password": "Passwort", + "divelogs_signIn_diver": "In Taucherprofil importieren", + "divelogs_signIn_connect": "Verbinden", + "divelogs_fetch_inProgress": "Tauchgänge werden von divelogs.de geladen...", + "divelogs_fetch_done": "Tauchgänge geladen.", + "divelogs_fetch_retry": "Erneut versuchen", + "divelogs_fetch_error": "Tauchgänge konnten nicht von divelogs.de geladen werden.", + "divelogs_fetch_wrongDiver": "Dieses divelogs.de-Konto ist mit einem anderen Taucherprofil verknüpft. Wechseln Sie das Taucherprofil, um zu importieren.", + "transfer_import_divelogs_title": "Von divelogs.de importieren", + "transfer_import_divelogs_subtitle": "Logbuch aus Ihrem divelogs.de-Konto laden", "diveLog_edit_geofenceSuggestion_near": "In der Nähe von {location}", "diveLog_edit_geofenceSuggestion_title": "Ausrüstungsvorschlag", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" übernehmen?", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a962343754..2e0d7c1cf9 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Sign in to divelogs.de", + "divelogs_signIn_username": "Username", + "divelogs_signIn_password": "Password", + "divelogs_signIn_diver": "Import into diver", + "divelogs_signIn_connect": "Connect", + "divelogs_fetch_inProgress": "Fetching dives from divelogs.de...", + "divelogs_fetch_done": "Dives fetched.", + "divelogs_fetch_retry": "Retry", + "divelogs_fetch_error": "Could not fetch dives from divelogs.de.", + "divelogs_fetch_wrongDiver": "This divelogs.de account is linked to a different diver profile. Switch divers to import.", + "transfer_import_divelogs_title": "Import from divelogs.de", + "transfer_import_divelogs_subtitle": "Pull your logbook from your divelogs.de account", "diveLog_edit_geofenceSuggestion_near": "Near {location}", "@diveLog_edit_geofenceSuggestion_near": { "placeholders": { "location": { "type": "String" } } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 24b1597aa5..81d2770e1d 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Iniciar sesión en divelogs.de", + "divelogs_signIn_username": "Nombre de usuario", + "divelogs_signIn_password": "Contraseña", + "divelogs_signIn_diver": "Importar al buceador", + "divelogs_signIn_connect": "Conectar", + "divelogs_fetch_inProgress": "Obteniendo inmersiones de divelogs.de...", + "divelogs_fetch_done": "Inmersiones obtenidas.", + "divelogs_fetch_retry": "Reintentar", + "divelogs_fetch_error": "No se pudieron obtener las inmersiones de divelogs.de.", + "divelogs_fetch_wrongDiver": "Esta cuenta de divelogs.de está vinculada a otro perfil de buceador. Cambia de buceador para importar.", + "transfer_import_divelogs_title": "Importar desde divelogs.de", + "transfer_import_divelogs_subtitle": "Obtén tu libro de buceo desde tu cuenta de divelogs.de", "diveLog_edit_geofenceSuggestion_near": "Cerca de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugerencia de equipo", "diveLog_edit_geofenceSuggestion_body": "¿Aplicar tu conjunto \"{setName}\"?", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 3a5e99855d..ca60d5a177 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Se connecter à divelogs.de", + "divelogs_signIn_username": "Nom d'utilisateur", + "divelogs_signIn_password": "Mot de passe", + "divelogs_signIn_diver": "Importer vers le plongeur", + "divelogs_signIn_connect": "Connexion", + "divelogs_fetch_inProgress": "Récupération des plongées depuis divelogs.de...", + "divelogs_fetch_done": "Plongées récupérées.", + "divelogs_fetch_retry": "Réessayer", + "divelogs_fetch_error": "Impossible de récupérer les plongées depuis divelogs.de.", + "divelogs_fetch_wrongDiver": "Ce compte divelogs.de est lié à un autre profil de plongeur. Changez de plongeur pour importer.", + "transfer_import_divelogs_title": "Importer depuis divelogs.de", + "transfer_import_divelogs_subtitle": "Récupérez votre carnet depuis votre compte divelogs.de", "diveLog_edit_geofenceSuggestion_near": "Près de {location}", "diveLog_edit_geofenceSuggestion_title": "Suggestion d'équipement", "diveLog_edit_geofenceSuggestion_body": "Appliquer l'ensemble \"{setName}\" ?", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 3b3b16adcc..df399a6b61 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "התחברות ל-divelogs.de", + "divelogs_signIn_username": "שם משתמש", + "divelogs_signIn_password": "סיסמה", + "divelogs_signIn_diver": "ייבוא לפרופיל צוללן", + "divelogs_signIn_connect": "התחבר", + "divelogs_fetch_inProgress": "מוריד צלילות מ-divelogs.de...", + "divelogs_fetch_done": "הצלילות הורדו.", + "divelogs_fetch_retry": "נסה שוב", + "divelogs_fetch_error": "לא ניתן להוריד צלילות מ-divelogs.de.", + "divelogs_fetch_wrongDiver": "חשבון divelogs.de זה מקושר לפרופיל צוללן אחר. החלף צוללן כדי לייבא.", + "transfer_import_divelogs_title": "ייבוא מ-divelogs.de", + "transfer_import_divelogs_subtitle": "משוך את יומן הצלילה מחשבון divelogs.de שלך", "diveLog_edit_geofenceSuggestion_near": "ליד {location}", "diveLog_edit_geofenceSuggestion_title": "הצעת ציוד", "diveLog_edit_geofenceSuggestion_body": "להחיל את ערכת \"{setName}\"?", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index b8e770b90e..390cf83cd0 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Bejelentkezés a divelogs.de-re", + "divelogs_signIn_username": "Felhasználónév", + "divelogs_signIn_password": "Jelszó", + "divelogs_signIn_diver": "Importálás ebbe a búvárprofilba", + "divelogs_signIn_connect": "Csatlakozás", + "divelogs_fetch_inProgress": "Merülések letöltése a divelogs.de-ről...", + "divelogs_fetch_done": "Merülések letöltve.", + "divelogs_fetch_retry": "Újra", + "divelogs_fetch_error": "Nem sikerült letölteni a merüléseket a divelogs.de-ről.", + "divelogs_fetch_wrongDiver": "Ez a divelogs.de-fiók másik búvárprofilhoz van kötve. Válts búvárprofilt az importáláshoz.", + "transfer_import_divelogs_title": "Importálás a divelogs.de-ről", + "transfer_import_divelogs_subtitle": "Töltsd le a naplódat a divelogs.de-fiókodból", "diveLog_edit_geofenceSuggestion_near": "{location} közelében", "diveLog_edit_geofenceSuggestion_title": "Felszerelési javaslat", "diveLog_edit_geofenceSuggestion_body": "Alkalmazza a(z) \"{setName}\" készletet?", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index c2ca2cbc10..cefb585f60 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Accedi a divelogs.de", + "divelogs_signIn_username": "Nome utente", + "divelogs_signIn_password": "Password", + "divelogs_signIn_diver": "Importa nel subacqueo", + "divelogs_signIn_connect": "Connetti", + "divelogs_fetch_inProgress": "Recupero delle immersioni da divelogs.de...", + "divelogs_fetch_done": "Immersioni recuperate.", + "divelogs_fetch_retry": "Riprova", + "divelogs_fetch_error": "Impossibile recuperare le immersioni da divelogs.de.", + "divelogs_fetch_wrongDiver": "Questo account divelogs.de è collegato a un altro profilo subacqueo. Cambia subacqueo per importare.", + "transfer_import_divelogs_title": "Importa da divelogs.de", + "transfer_import_divelogs_subtitle": "Recupera il tuo logbook dal tuo account divelogs.de", "diveLog_edit_geofenceSuggestion_near": "Vicino a {location}", "diveLog_edit_geofenceSuggestion_title": "Suggerimento attrezzatura", "diveLog_edit_geofenceSuggestion_body": "Applicare il set \"{setName}\"?", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5aa0ed5df4..0bd6f21412 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -116,6 +116,78 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @divelogs_signIn_title. + /// + /// In en, this message translates to: + /// **'Sign in to divelogs.de'** + String get divelogs_signIn_title; + + /// No description provided for @divelogs_signIn_username. + /// + /// In en, this message translates to: + /// **'Username'** + String get divelogs_signIn_username; + + /// No description provided for @divelogs_signIn_password. + /// + /// In en, this message translates to: + /// **'Password'** + String get divelogs_signIn_password; + + /// No description provided for @divelogs_signIn_diver. + /// + /// In en, this message translates to: + /// **'Import into diver'** + String get divelogs_signIn_diver; + + /// No description provided for @divelogs_signIn_connect. + /// + /// In en, this message translates to: + /// **'Connect'** + String get divelogs_signIn_connect; + + /// No description provided for @divelogs_fetch_inProgress. + /// + /// In en, this message translates to: + /// **'Fetching dives from divelogs.de...'** + String get divelogs_fetch_inProgress; + + /// No description provided for @divelogs_fetch_done. + /// + /// In en, this message translates to: + /// **'Dives fetched.'** + String get divelogs_fetch_done; + + /// No description provided for @divelogs_fetch_retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get divelogs_fetch_retry; + + /// No description provided for @divelogs_fetch_error. + /// + /// In en, this message translates to: + /// **'Could not fetch dives from divelogs.de.'** + String get divelogs_fetch_error; + + /// No description provided for @divelogs_fetch_wrongDiver. + /// + /// In en, this message translates to: + /// **'This divelogs.de account is linked to a different diver profile. Switch divers to import.'** + String get divelogs_fetch_wrongDiver; + + /// No description provided for @transfer_import_divelogs_title. + /// + /// In en, this message translates to: + /// **'Import from divelogs.de'** + String get transfer_import_divelogs_title; + + /// No description provided for @transfer_import_divelogs_subtitle. + /// + /// In en, this message translates to: + /// **'Pull your logbook from your divelogs.de account'** + String get transfer_import_divelogs_subtitle; + /// No description provided for @diveLog_edit_geofenceSuggestion_near. /// /// 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 829780e43e..117eb58479 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8,6 +8,44 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get divelogs_signIn_title => 'تسجيل الدخول إلى divelogs.de'; + + @override + String get divelogs_signIn_username => 'اسم المستخدم'; + + @override + String get divelogs_signIn_password => 'كلمة المرور'; + + @override + String get divelogs_signIn_diver => 'الاستيراد إلى الغواص'; + + @override + String get divelogs_signIn_connect => 'اتصال'; + + @override + String get divelogs_fetch_inProgress => 'جارٍ جلب الغطسات من divelogs.de...'; + + @override + String get divelogs_fetch_done => 'تم جلب الغطسات.'; + + @override + String get divelogs_fetch_retry => 'إعادة المحاولة'; + + @override + String get divelogs_fetch_error => 'تعذر جلب الغطسات من divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'حساب divelogs.de هذا مرتبط بملف غواص آخر. بدّل الغواص للاستيراد.'; + + @override + String get transfer_import_divelogs_title => 'استيراد من divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'اجلب سجل غطساتك من حسابك على divelogs.de'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'بالقرب من $location'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 64013a096b..241655f994 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8,6 +8,46 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get divelogs_signIn_title => 'Bei divelogs.de anmelden'; + + @override + String get divelogs_signIn_username => 'Benutzername'; + + @override + String get divelogs_signIn_password => 'Passwort'; + + @override + String get divelogs_signIn_diver => 'In Taucherprofil importieren'; + + @override + String get divelogs_signIn_connect => 'Verbinden'; + + @override + String get divelogs_fetch_inProgress => + 'Tauchgänge werden von divelogs.de geladen...'; + + @override + String get divelogs_fetch_done => 'Tauchgänge geladen.'; + + @override + String get divelogs_fetch_retry => 'Erneut versuchen'; + + @override + String get divelogs_fetch_error => + 'Tauchgänge konnten nicht von divelogs.de geladen werden.'; + + @override + String get divelogs_fetch_wrongDiver => + 'Dieses divelogs.de-Konto ist mit einem anderen Taucherprofil verknüpft. Wechseln Sie das Taucherprofil, um zu importieren.'; + + @override + String get transfer_import_divelogs_title => 'Von divelogs.de importieren'; + + @override + String get transfer_import_divelogs_subtitle => + 'Logbuch aus Ihrem divelogs.de-Konto laden'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'In der Nähe von $location'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 6eef66fb94..b82c6dba39 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8,6 +8,44 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get divelogs_signIn_title => 'Sign in to divelogs.de'; + + @override + String get divelogs_signIn_username => 'Username'; + + @override + String get divelogs_signIn_password => 'Password'; + + @override + String get divelogs_signIn_diver => 'Import into diver'; + + @override + String get divelogs_signIn_connect => 'Connect'; + + @override + String get divelogs_fetch_inProgress => 'Fetching dives from divelogs.de...'; + + @override + String get divelogs_fetch_done => 'Dives fetched.'; + + @override + String get divelogs_fetch_retry => 'Retry'; + + @override + String get divelogs_fetch_error => 'Could not fetch dives from divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'This divelogs.de account is linked to a different diver profile. Switch divers to import.'; + + @override + String get transfer_import_divelogs_title => 'Import from divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'Pull your logbook from your divelogs.de account'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Near $location'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 6fe60faf1b..d19a8fb537 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8,6 +8,46 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get divelogs_signIn_title => 'Iniciar sesión en divelogs.de'; + + @override + String get divelogs_signIn_username => 'Nombre de usuario'; + + @override + String get divelogs_signIn_password => 'Contraseña'; + + @override + String get divelogs_signIn_diver => 'Importar al buceador'; + + @override + String get divelogs_signIn_connect => 'Conectar'; + + @override + String get divelogs_fetch_inProgress => + 'Obteniendo inmersiones de divelogs.de...'; + + @override + String get divelogs_fetch_done => 'Inmersiones obtenidas.'; + + @override + String get divelogs_fetch_retry => 'Reintentar'; + + @override + String get divelogs_fetch_error => + 'No se pudieron obtener las inmersiones de divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'Esta cuenta de divelogs.de está vinculada a otro perfil de buceador. Cambia de buceador para importar.'; + + @override + String get transfer_import_divelogs_title => 'Importar desde divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'Obtén tu libro de buceo desde tu cuenta de divelogs.de'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Cerca de $location'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index d672675956..30bc69ecfd 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8,6 +8,46 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get divelogs_signIn_title => 'Se connecter à divelogs.de'; + + @override + String get divelogs_signIn_username => 'Nom d\'utilisateur'; + + @override + String get divelogs_signIn_password => 'Mot de passe'; + + @override + String get divelogs_signIn_diver => 'Importer vers le plongeur'; + + @override + String get divelogs_signIn_connect => 'Connexion'; + + @override + String get divelogs_fetch_inProgress => + 'Récupération des plongées depuis divelogs.de...'; + + @override + String get divelogs_fetch_done => 'Plongées récupérées.'; + + @override + String get divelogs_fetch_retry => 'Réessayer'; + + @override + String get divelogs_fetch_error => + 'Impossible de récupérer les plongées depuis divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'Ce compte divelogs.de est lié à un autre profil de plongeur. Changez de plongeur pour importer.'; + + @override + String get transfer_import_divelogs_title => 'Importer depuis divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'Récupérez votre carnet depuis votre compte divelogs.de'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Près de $location'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 5ff4afd3d2..4bdc7df28f 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8,6 +8,44 @@ import 'app_localizations.dart'; class AppLocalizationsHe extends AppLocalizations { AppLocalizationsHe([String locale = 'he']) : super(locale); + @override + String get divelogs_signIn_title => 'התחברות ל-divelogs.de'; + + @override + String get divelogs_signIn_username => 'שם משתמש'; + + @override + String get divelogs_signIn_password => 'סיסמה'; + + @override + String get divelogs_signIn_diver => 'ייבוא לפרופיל צוללן'; + + @override + String get divelogs_signIn_connect => 'התחבר'; + + @override + String get divelogs_fetch_inProgress => 'מוריד צלילות מ-divelogs.de...'; + + @override + String get divelogs_fetch_done => 'הצלילות הורדו.'; + + @override + String get divelogs_fetch_retry => 'נסה שוב'; + + @override + String get divelogs_fetch_error => 'לא ניתן להוריד צלילות מ-divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'חשבון divelogs.de זה מקושר לפרופיל צוללן אחר. החלף צוללן כדי לייבא.'; + + @override + String get transfer_import_divelogs_title => 'ייבוא מ-divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'משוך את יומן הצלילה מחשבון divelogs.de שלך'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'ליד $location'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index e6bcacdd02..8f418f0ea2 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8,6 +8,46 @@ import 'app_localizations.dart'; class AppLocalizationsHu extends AppLocalizations { AppLocalizationsHu([String locale = 'hu']) : super(locale); + @override + String get divelogs_signIn_title => 'Bejelentkezés a divelogs.de-re'; + + @override + String get divelogs_signIn_username => 'Felhasználónév'; + + @override + String get divelogs_signIn_password => 'Jelszó'; + + @override + String get divelogs_signIn_diver => 'Importálás ebbe a búvárprofilba'; + + @override + String get divelogs_signIn_connect => 'Csatlakozás'; + + @override + String get divelogs_fetch_inProgress => + 'Merülések letöltése a divelogs.de-ről...'; + + @override + String get divelogs_fetch_done => 'Merülések letöltve.'; + + @override + String get divelogs_fetch_retry => 'Újra'; + + @override + String get divelogs_fetch_error => + 'Nem sikerült letölteni a merüléseket a divelogs.de-ről.'; + + @override + String get divelogs_fetch_wrongDiver => + 'Ez a divelogs.de-fiók másik búvárprofilhoz van kötve. Válts búvárprofilt az importáláshoz.'; + + @override + String get transfer_import_divelogs_title => 'Importálás a divelogs.de-ről'; + + @override + String get transfer_import_divelogs_subtitle => + 'Töltsd le a naplódat a divelogs.de-fiókodból'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return '$location közelében'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index b895f2f354..b1709369df 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8,6 +8,46 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get divelogs_signIn_title => 'Accedi a divelogs.de'; + + @override + String get divelogs_signIn_username => 'Nome utente'; + + @override + String get divelogs_signIn_password => 'Password'; + + @override + String get divelogs_signIn_diver => 'Importa nel subacqueo'; + + @override + String get divelogs_signIn_connect => 'Connetti'; + + @override + String get divelogs_fetch_inProgress => + 'Recupero delle immersioni da divelogs.de...'; + + @override + String get divelogs_fetch_done => 'Immersioni recuperate.'; + + @override + String get divelogs_fetch_retry => 'Riprova'; + + @override + String get divelogs_fetch_error => + 'Impossibile recuperare le immersioni da divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'Questo account divelogs.de è collegato a un altro profilo subacqueo. Cambia subacqueo per importare.'; + + @override + String get transfer_import_divelogs_title => 'Importa da divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'Recupera il tuo logbook dal tuo account divelogs.de'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Vicino a $location'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index f8d9295874..69c2e70085 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8,6 +8,44 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String get divelogs_signIn_title => 'Aanmelden bij divelogs.de'; + + @override + String get divelogs_signIn_username => 'Gebruikersnaam'; + + @override + String get divelogs_signIn_password => 'Wachtwoord'; + + @override + String get divelogs_signIn_diver => 'Importeren naar duiker'; + + @override + String get divelogs_signIn_connect => 'Verbinden'; + + @override + String get divelogs_fetch_inProgress => 'Duiken ophalen van divelogs.de...'; + + @override + String get divelogs_fetch_done => 'Duiken opgehaald.'; + + @override + String get divelogs_fetch_retry => 'Opnieuw proberen'; + + @override + String get divelogs_fetch_error => 'Kon geen duiken ophalen van divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'Dit divelogs.de-account is gekoppeld aan een ander duikersprofiel. Wissel van duiker om te importeren.'; + + @override + String get transfer_import_divelogs_title => 'Importeren van divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'Haal je logboek op uit je divelogs.de-account'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Bij $location'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index eb29c6ee45..0773cdedf7 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8,6 +8,46 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get divelogs_signIn_title => 'Iniciar sessão em divelogs.de'; + + @override + String get divelogs_signIn_username => 'Nome de usuário'; + + @override + String get divelogs_signIn_password => 'Senha'; + + @override + String get divelogs_signIn_diver => 'Importar para o mergulhador'; + + @override + String get divelogs_signIn_connect => 'Conectar'; + + @override + String get divelogs_fetch_inProgress => + 'Buscando mergulhos de divelogs.de...'; + + @override + String get divelogs_fetch_done => 'Mergulhos obtidos.'; + + @override + String get divelogs_fetch_retry => 'Tentar novamente'; + + @override + String get divelogs_fetch_error => + 'Não foi possível obter os mergulhos de divelogs.de.'; + + @override + String get divelogs_fetch_wrongDiver => + 'Esta conta divelogs.de está vinculada a outro perfil de mergulhador. Troque de mergulhador para importar.'; + + @override + String get transfer_import_divelogs_title => 'Importar de divelogs.de'; + + @override + String get transfer_import_divelogs_subtitle => + 'Busque seu livro de registro da sua conta divelogs.de'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Perto de $location'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index dabb31930a..f3bfd872da 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get divelogs_signIn_title => '登录 divelogs.de'; + + @override + String get divelogs_signIn_username => '用户名'; + + @override + String get divelogs_signIn_password => '密码'; + + @override + String get divelogs_signIn_diver => '导入到潜水员'; + + @override + String get divelogs_signIn_connect => '连接'; + + @override + String get divelogs_fetch_inProgress => '正在从 divelogs.de 获取潜水记录...'; + + @override + String get divelogs_fetch_done => '潜水记录已获取。'; + + @override + String get divelogs_fetch_retry => '重试'; + + @override + String get divelogs_fetch_error => '无法从 divelogs.de 获取潜水记录。'; + + @override + String get divelogs_fetch_wrongDiver => + '此 divelogs.de 账户已关联其他潜水员档案。请切换潜水员后再导入。'; + + @override + String get transfer_import_divelogs_title => '从 divelogs.de 导入'; + + @override + String get transfer_import_divelogs_subtitle => '从您的 divelogs.de 账户拉取潜水日志'; + @override String diveLog_edit_geofenceSuggestion_near(String location) { return '靠近 $location'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index d134a405c4..9aedfd136d 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Aanmelden bij divelogs.de", + "divelogs_signIn_username": "Gebruikersnaam", + "divelogs_signIn_password": "Wachtwoord", + "divelogs_signIn_diver": "Importeren naar duiker", + "divelogs_signIn_connect": "Verbinden", + "divelogs_fetch_inProgress": "Duiken ophalen van divelogs.de...", + "divelogs_fetch_done": "Duiken opgehaald.", + "divelogs_fetch_retry": "Opnieuw proberen", + "divelogs_fetch_error": "Kon geen duiken ophalen van divelogs.de.", + "divelogs_fetch_wrongDiver": "Dit divelogs.de-account is gekoppeld aan een ander duikersprofiel. Wissel van duiker om te importeren.", + "transfer_import_divelogs_title": "Importeren van divelogs.de", + "transfer_import_divelogs_subtitle": "Haal je logboek op uit je divelogs.de-account", "diveLog_edit_geofenceSuggestion_near": "Bij {location}", "diveLog_edit_geofenceSuggestion_title": "Uitrustingssuggestie", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" toepassen?", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 93da5e502f..5c7b258310 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "Iniciar sessão em divelogs.de", + "divelogs_signIn_username": "Nome de usuário", + "divelogs_signIn_password": "Senha", + "divelogs_signIn_diver": "Importar para o mergulhador", + "divelogs_signIn_connect": "Conectar", + "divelogs_fetch_inProgress": "Buscando mergulhos de divelogs.de...", + "divelogs_fetch_done": "Mergulhos obtidos.", + "divelogs_fetch_retry": "Tentar novamente", + "divelogs_fetch_error": "Não foi possível obter os mergulhos de divelogs.de.", + "divelogs_fetch_wrongDiver": "Esta conta divelogs.de está vinculada a outro perfil de mergulhador. Troque de mergulhador para importar.", + "transfer_import_divelogs_title": "Importar de divelogs.de", + "transfer_import_divelogs_subtitle": "Busque seu livro de registro da sua conta divelogs.de", "diveLog_edit_geofenceSuggestion_near": "Perto de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugestão de equipamento", "diveLog_edit_geofenceSuggestion_body": "Aplicar o conjunto \"{setName}\"?", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 49baffcfab..e5a02f8979 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1,4 +1,16 @@ { + "divelogs_signIn_title": "登录 divelogs.de", + "divelogs_signIn_username": "用户名", + "divelogs_signIn_password": "密码", + "divelogs_signIn_diver": "导入到潜水员", + "divelogs_signIn_connect": "连接", + "divelogs_fetch_inProgress": "正在从 divelogs.de 获取潜水记录...", + "divelogs_fetch_done": "潜水记录已获取。", + "divelogs_fetch_retry": "重试", + "divelogs_fetch_error": "无法从 divelogs.de 获取潜水记录。", + "divelogs_fetch_wrongDiver": "此 divelogs.de 账户已关联其他潜水员档案。请切换潜水员后再导入。", + "transfer_import_divelogs_title": "从 divelogs.de 导入", + "transfer_import_divelogs_subtitle": "从您的 divelogs.de 账户拉取潜水日志", "diveLog_edit_geofenceSuggestion_near": "靠近 {location}", "diveLog_edit_geofenceSuggestion_title": "装备建议", "diveLog_edit_geofenceSuggestion_body": "应用\"{setName}\"套装?", diff --git a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart index 962f164856..5d539e169e 100644 --- a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart @@ -14,6 +14,7 @@ import 'package:submersion/features/divers/presentation/providers/diver_provider import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../../helpers/test_database.dart'; @@ -51,6 +52,8 @@ void main() { ], child: const MaterialApp( themeAnimationDuration: Duration.zero, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: DivelogsFetchStep()), ), ); From 1caf500780ea91ceb579e522c18559b0d451da7b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 19:22:58 -0400 Subject: [PATCH 12/35] test: update schema tripwire and migration ladder for v115 --- lib/core/database/database.dart | 1 + test/core/database/equipment_set_geofence_schema_test.dart | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index ee1ce4e07a..7ea1e0399e 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -2325,6 +2325,7 @@ class AppDatabase extends _$AppDatabase { 110, 111, 112, + 115, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom diff --git a/test/core/database/equipment_set_geofence_schema_test.dart b/test/core/database/equipment_set_geofence_schema_test.dart index a3a122bb8d..b75daed8b2 100644 --- a/test/core/database/equipment_set_geofence_schema_test.dart +++ b/test/core/database/equipment_set_geofence_schema_test.dart @@ -74,8 +74,8 @@ void main() { }, ); - test('v112 is the current schema version (exact-latest tripwire)', () { - expect(AppDatabase.currentSchemaVersion, 112); - expect(AppDatabase.migrationVersions, contains(112)); + test('v115 is the current schema version (exact-latest tripwire)', () { + expect(AppDatabase.currentSchemaVersion, 115); + expect(AppDatabase.migrationVersions, contains(115)); }); } From 301d78a03dad447f5a7e29497f3888a6b859dd94 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 22:37:57 -0400 Subject: [PATCH 13/35] fix: renumber diver_id migration to v116 (v115 claimed by PR #602) --- lib/core/database/database.dart | 12 ++++++------ .../database/equipment_set_geofence_schema_test.dart | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 7ea1e0399e..420fdecd39 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -2209,7 +2209,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 = 115; + static const int currentSchemaVersion = 116; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -2325,7 +2325,7 @@ class AppDatabase extends _$AppDatabase { 110, 111, 112, - 115, + 116, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -2421,7 +2421,7 @@ class AppDatabase extends _$AppDatabase { } } - /// v115: connected_accounts.diver_id column (divelogs.de diver binding). + /// v116: connected_accounts.diver_id column (divelogs.de diver binding). /// Idempotent so it is safe to call from both onUpgrade and the beforeOpen /// backstop. Future _assertConnectedAccountsDiverIdColumn() async { @@ -5564,10 +5564,10 @@ class AppDatabase extends _$AppDatabase { await _assertEquipmentThicknessColumn(); } if (from < 112) await reportProgress(); - if (from < 115) { + if (from < 116) { await _assertConnectedAccountsDiverIdColumn(); } - if (from < 115) await reportProgress(); + if (from < 116) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -5601,7 +5601,7 @@ class AppDatabase extends _$AppDatabase { // v112 backstop: re-assert equipment.thickness column. await _assertEquipmentThicknessColumn(); - // v115 backstop: re-assert connected_accounts.diver_id column. + // v116 backstop: re-assert connected_accounts.diver_id column. await _assertConnectedAccountsDiverIdColumn(); // Built-in dive types are reference data: identical on every device and diff --git a/test/core/database/equipment_set_geofence_schema_test.dart b/test/core/database/equipment_set_geofence_schema_test.dart index b75daed8b2..bb934363ee 100644 --- a/test/core/database/equipment_set_geofence_schema_test.dart +++ b/test/core/database/equipment_set_geofence_schema_test.dart @@ -74,8 +74,8 @@ void main() { }, ); - test('v115 is the current schema version (exact-latest tripwire)', () { - expect(AppDatabase.currentSchemaVersion, 115); - expect(AppDatabase.migrationVersions, contains(115)); + test('v116 is the current schema version (exact-latest tripwire)', () { + expect(AppDatabase.currentSchemaVersion, 116); + expect(AppDatabase.migrationVersions, contains(116)); }); } From bb53bbf05914e2ce5a4e3c5aad9ba10005c6ea50 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 23:05:10 -0400 Subject: [PATCH 14/35] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20UTC=20wall-clock=20timestamps,=20jsonDecode=20guards,=20revi?= =?UTF-8?q?ew=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse divelogs date/time as wall-clock UTC (pipeline convention; local DateTime shifted dives by device timezone and broke duplicate matching) - Convert non-JSON response bodies to DivelogsApiException instead of an unhandled FormatException - setExternalPayload catches duplicate-check failures and reports via state error instead of stranding isLoading - Lock the username field when reconnecting an existing account so accountIdentifier cannot drift from the stored credentials - Backfill site GPS when a later dive has coordinates the first lacked - Pluralize the skipped-dives warning; pin widget-test locale; non-null divelogsHttpClientProvider (weatherHttpClientProvider pattern) --- .../divelogs/divelogs_api_client.dart | 15 +++++++-- .../services/divelogs/divelogs_models.dart | 5 ++- .../data/adapters/divelogs_adapter.dart | 8 +++-- .../widgets/divelogs_fetch_step.dart | 11 +++++-- .../services/divelogs_import_service.dart | 18 +++++++--- .../providers/universal_import_providers.dart | 33 +++++++++++++------ .../divelogs/divelogs_models_test.dart | 2 +- .../widgets/divelogs_fetch_step_test.dart | 1 + .../services/divelogs_dive_mapper_test.dart | 6 ++-- .../divelogs_import_service_test.dart | 9 +++-- 10 files changed, 78 insertions(+), 30 deletions(-) diff --git a/lib/core/services/divelogs/divelogs_api_client.dart b/lib/core/services/divelogs/divelogs_api_client.dart index 26f5672106..d71cfb0d54 100644 --- a/lib/core/services/divelogs/divelogs_api_client.dart +++ b/lib/core/services/divelogs/divelogs_api_client.dart @@ -35,7 +35,7 @@ class DivelogsApiClient { Future> getUser() async { final response = await _get('/user'); - final decoded = jsonDecode(response.body); + final decoded = _decode(response.body, '/user'); if (decoded is! Map) { throw const DivelogsApiException(0, 'Unexpected /user response'); } @@ -44,7 +44,7 @@ class DivelogsApiClient { Future getAllDives() async { final response = await _get('/dives'); - final decoded = jsonDecode(response.body); + final decoded = _decode(response.body, '/dives'); final List rawDives; if (decoded is List) { rawDives = decoded; @@ -69,6 +69,17 @@ class DivelogsApiClient { return DivelogsDivesResult(dives: dives, skippedCount: skipped); } + /// Decodes a response body, converting FormatException (non-JSON error + /// pages, proxy-injected HTML) into the retryable DivelogsApiException the + /// UI already handles. + Object? _decode(String body, String endpoint) { + try { + return jsonDecode(body); + } on FormatException { + throw DivelogsApiException(0, 'Unexpected $endpoint response'); + } + } + Future _get(String path) async { var authRetried = false; while (true) { diff --git a/lib/core/services/divelogs/divelogs_models.dart b/lib/core/services/divelogs/divelogs_models.dart index 34f11d5e6b..385d95f3a8 100644 --- a/lib/core/services/divelogs/divelogs_models.dart +++ b/lib/core/services/divelogs/divelogs_models.dart @@ -123,9 +123,12 @@ class DivelogsDive { if (date == null || duration == null || maxDepth == null) { throw FormatException('divelogs dive missing mandatory fields', json); } + // Dive timestamps are wall-clock; the import pipeline convention is to + // represent wall-clock as UTC (matches the Subsurface parser and DB + // loads with isUtc: true), so parse with an explicit Z suffix. final DateTime dateTime; try { - dateTime = DateTime.parse('$date $time'); + dateTime = DateTime.parse('${date}T${time}Z'); } on FormatException { throw FormatException('divelogs dive has unparseable date/time', json); } diff --git a/lib/features/import_wizard/data/adapters/divelogs_adapter.dart b/lib/features/import_wizard/data/adapters/divelogs_adapter.dart index 177d56fb68..736f891239 100644 --- a/lib/features/import_wizard/data/adapters/divelogs_adapter.dart +++ b/lib/features/import_wizard/data/adapters/divelogs_adapter.dart @@ -13,9 +13,11 @@ final divelogsPayloadReadyProvider = Provider( (ref) => ref.watch(universalImportNotifierProvider).payload != null, ); -/// HTTP client for divelogs.de calls; null means the real network client. -/// Overridable so widget tests can supply a MockClient. -final divelogsHttpClientProvider = Provider((ref) => null); +/// HTTP client for divelogs.de calls. Overridable so widget tests can +/// supply a MockClient (pattern: weatherHttpClientProvider). +final divelogsHttpClientProvider = Provider( + (ref) => http.Client(), +); /// Import source that pulls the user's logbook from divelogs.de. /// diff --git a/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart index 9e25ba3bdf..6829abb529 100644 --- a/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart +++ b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart @@ -149,11 +149,14 @@ class _DivelogsFetchStepState extends ConsumerState { ); final payload = await DivelogsImportService(api: api).fetchAllDives(); if (!mounted) return; - await ref + final installed = await ref .read(universalImportNotifierProvider.notifier) .setExternalPayload(payload); if (!mounted) return; - setState(() => _phase = _StepPhase.done); + setState(() { + _phase = installed ? _StepPhase.done : _StepPhase.error; + if (!installed) _errorMessage = null; + }); } on DivelogsApiException catch (e) { if (!mounted) return; setState(() { @@ -205,7 +208,9 @@ class _DivelogsFetchStepState extends ConsumerState { labelText: context.l10n.divelogs_signIn_username, ), autocorrect: false, - enabled: !_connecting, + // Reconnecting an existing account: the username identifies the + // account row (accountIdentifier) and must not drift from it. + enabled: !_connecting && _account == null, ), const SizedBox(height: 12), TextField( diff --git a/lib/features/universal_import/data/services/divelogs_import_service.dart b/lib/features/universal_import/data/services/divelogs_import_service.dart index 19f637ed28..de4b9c0b93 100644 --- a/lib/features/universal_import/data/services/divelogs_import_service.dart +++ b/lib/features/universal_import/data/services/divelogs_import_service.dart @@ -25,7 +25,16 @@ class DivelogsImportService { diveEntities.add(_mapper.mapDive(dive)); final site = _mapper.mapSite(dive); if (site != null) { - sitesByKey.putIfAbsent(site['uddfId'] as String, () => site); + final existing = sitesByKey[site['uddfId'] as String]; + if (existing == null) { + sitesByKey[site['uddfId'] as String] = site; + } else { + // Same site seen on an earlier dive: backfill GPS the first + // occurrence lacked so location data is not dropped. + existing.putIfAbsent('latitude', () => site['latitude']); + existing.putIfAbsent('longitude', () => site['longitude']); + existing.removeWhere((_, value) => value == null); + } } } @@ -43,9 +52,10 @@ class DivelogsImportService { if (result.skippedCount > 0) ImportWarning( severity: ImportWarningSeverity.warning, - message: - '${result.skippedCount} dives could not be read from ' - 'divelogs.de and were skipped.', + message: result.skippedCount == 1 + ? '1 dive could not be read from divelogs.de and was skipped.' + : '${result.skippedCount} dives could not be read from ' + 'divelogs.de and were skipped.', ), ], metadata: {'source': 'divelogs.de', 'diveCount': result.dives.length}, 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 2e48577c54..1e18b1c510 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -678,17 +678,30 @@ class UniversalImportNotifier extends StateNotifier { /// Installs a payload produced outside the file-parse path (e.g. a REST /// source like divelogs.de) and runs the standard duplicate check and /// default-selection pass so the wizard can proceed to review. - Future setExternalPayload(ImportPayload payload) async { + /// + /// Returns false (leaving the payload unset and an error on the state) + /// when duplicate checking fails, so callers can surface a retryable + /// error instead of stranding the wizard in a loading state. + Future setExternalPayload(ImportPayload payload) async { state = state.copyWith(isLoading: true, clearError: true); - final dupResult = await _checkDuplicates(payload); - final selections = _defaultSelections(payload, dupResult); - state = state.copyWith( - isLoading: false, - payload: payload, - duplicateResult: dupResult, - selections: selections, - currentStep: ImportWizardStep.review, - ); + try { + final dupResult = await _checkDuplicates(payload); + final selections = _defaultSelections(payload, dupResult); + state = state.copyWith( + isLoading: false, + payload: payload, + duplicateResult: dupResult, + selections: selections, + currentStep: ImportWizardStep.review, + ); + return true; + } catch (e) { + state = state.copyWith( + isLoading: false, + error: 'Failed to prepare import: $e', + ); + return false; + } } // -- Parsing + Duplicate Check -- diff --git a/test/core/services/divelogs/divelogs_models_test.dart b/test/core/services/divelogs/divelogs_models_test.dart index a390c3f1c4..ed185ec378 100644 --- a/test/core/services/divelogs/divelogs_models_test.dart +++ b/test/core/services/divelogs/divelogs_models_test.dart @@ -13,7 +13,7 @@ void main() { test('parses mandatory fields', () { final dive = DivelogsDive.fromJson(minimal()); expect(dive.id, '4711'); - expect(dive.dateTime, DateTime(2022, 9, 3, 14, 42)); + expect(dive.dateTime, DateTime.utc(2022, 9, 3, 14, 42)); expect(dive.durationSeconds, 2808); expect(dive.maxDepth, 12.0); expect(dive.samples, isEmpty); diff --git a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart index 5d539e169e..b029e23f0f 100644 --- a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart @@ -51,6 +51,7 @@ void main() { currentDiverProvider.overrideWith((ref) async => diver), ], child: const MaterialApp( + locale: Locale('en'), themeAnimationDuration: Duration.zero, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, diff --git a/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart b/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart index 5da1453a59..fcb3222524 100644 --- a/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart +++ b/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart @@ -13,7 +13,7 @@ void main() { double? lng = 35.1, }) => DivelogsDive( id: id, - dateTime: DateTime(2022, 9, 3, 14, 42), + dateTime: DateTime.utc(2022, 9, 3, 14, 42), durationSeconds: 2808, maxDepth: 12, meanDepth: 7.9, @@ -50,7 +50,7 @@ void main() { test('maps core fields with importer-compatible keys', () { final map = mapper.mapDive(dive()); - expect(map['dateTime'], DateTime(2022, 9, 3, 14, 42)); + expect(map['dateTime'], DateTime.utc(2022, 9, 3, 14, 42)); expect(map['runtime'], const Duration(seconds: 2808)); expect(map['maxDepth'], 12.0); expect(map['avgDepth'], 7.9); @@ -114,7 +114,7 @@ void main() { test('zero weights and temps are treated as unset', () { final d = DivelogsDive( - dateTime: DateTime(2022), + dateTime: DateTime.utc(2022), durationSeconds: 60, maxDepth: 5, weightsKg: 0, diff --git a/test/features/universal_import/data/services/divelogs_import_service_test.dart b/test/features/universal_import/data/services/divelogs_import_service_test.dart index 742986a56d..26724152f9 100644 --- a/test/features/universal_import/data/services/divelogs_import_service_test.dart +++ b/test/features/universal_import/data/services/divelogs_import_service_test.dart @@ -62,14 +62,17 @@ void main() { expect(payload.entitiesOf(ImportEntityType.dives), hasLength(1)); expect(payload.warnings, hasLength(1)); expect(payload.warnings.single.severity, ImportWarningSeverity.warning); - expect(payload.warnings.single.message, contains('1 dives')); + expect( + payload.warnings.single.message, + '1 dive could not be read from divelogs.de and was skipped.', + ); }); group('duplicate checker integration', () { final existingDive = Dive( id: 'existing-1', - dateTime: DateTime(2022, 9, 3, 14, 42), - entryTime: DateTime(2022, 9, 3, 14, 42), + dateTime: DateTime.utc(2022, 9, 3, 14, 42), + entryTime: DateTime.utc(2022, 9, 3, 14, 42), runtime: const Duration(seconds: 2808), maxDepth: 12, ); From 4efa56e7bf0c9c6d277c7d23636ba77a79d0bd1c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 16 Jul 2026 23:16:52 -0400 Subject: [PATCH 15/35] docs: add divelogs.de sync phase 2 implementation plan --- .../plans/2026-07-16-divelogs-sync-phase2.md | 1248 +++++++++++++++++ 1 file changed, 1248 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-divelogs-sync-phase2.md diff --git a/docs/superpowers/plans/2026-07-16-divelogs-sync-phase2.md b/docs/superpowers/plans/2026-07-16-divelogs-sync-phase2.md new file mode 100644 index 0000000000..e746eed36d --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-divelogs-sync-phase2.md @@ -0,0 +1,1248 @@ +# divelogs.de Sync — Phase 2 (Push Dives + Compare/Review Sync Page) 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:** Let users push local dives to divelogs.de and see a cheap two-way compare ("N to pull / M to push") on a dedicated sync page reachable from the Connected Accounts roster. + +**Architecture:** A stateless planner diffs the remote `GET /divelist` against local `DiveSummary` rows using the #494 time-gated matcher; unmatched remote dives are pull candidates, unmatched local dives are push candidates. Push is a lossy projection (`DivelogsExportMapper`) committed via chunked `POST /dives`. Pull continues to go through the Phase 1 import wizard (which already has fetch + dedup + review); the sync page links to it rather than duplicating that flow. + +**Tech Stack:** Flutter/Dart, Riverpod (plain), `package:http` + `MockClient`, Phase 1's `DivelogsApiClient`/`DivelogsAuthManager`/`DivelogsAccountAdapter`. + +**Spec:** `docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md` (Phase 2 sections: push path, sync page). + +**Stacking:** builds on branch `worktree-divelogs-sync` (Phase 1, PR #603). If #603 has merged, branch from main instead and adjust nothing else. + +## Global Constraints + +- Same as Phase 1: metric domain units; wall-clock timestamps represented as UTC (`DateTime.utc` / parse with `Z` suffix); `dart format .` clean before every commit; no emojis; no Co-Authored-By or session URL in commits; new user-facing strings into `app_en.arb` AND all 10 non-English locales, then `flutter gen-l10n`; run tests per-file; push with `--no-verify` (hook checks main tree) after in-worktree verification. +- API mandatory fields for a pushed dive: `date`, `time`, `duration` (seconds), `maxdepth`. A local dive that cannot produce all four is UNPUSHABLE and is excluded with a count shown to the user, never sent. +- Stateless create-only model: push never updates or deletes; nothing is written back onto local dives after a push. Re-running compare after a push must show the pushed dives as matched. +- `GET /divelist`'s response shape is unconfirmed (spec open question 3, asked of Rainer). The divelist model must parse tolerantly and the planner must degrade to time-only matching when depth/duration are absent. Revisit when Rainer answers. +- Chunk size for `POST /dives`: 50 dives per request (spec assumption, open question 5), with a 200 ms courtesy delay between chunks. + +--- + +### Task 1: Divelist model + API client `getDivelist()` / `postDives()` + +**Files:** +- Modify: `lib/core/services/divelogs/divelogs_models.dart` +- Modify: `lib/core/services/divelogs/divelogs_api_client.dart` +- Test: `test/core/services/divelogs/divelogs_models_test.dart` (extend) +- Test: `test/core/services/divelogs/divelogs_api_client_test.dart` (extend) + +**Interfaces:** +- Consumes: Phase 1's `DivelogsApiClient` internals (`_get`, `_decode`, `_baseUri`, 401-retry loop). +- Produces: + - `class DivelogsDivelistEntry { final String id; final DateTime dateTime; final int? durationSeconds; final double? maxDepth; static DivelogsDivelistEntry? fromJson(Map json); }` — returns null (not throws) when id or date/time are unusable; `dateTime` is wall-clock UTC. + - `class DivelogsDivelistResult { final List entries; final int skippedCount; }` + - On `DivelogsApiClient`: `Future getDivelist()` and `Future postDives(List> dives)` (throws `DivelogsApiException` on non-2xx; one 401 retry like `_get`). + +- [ ] **Step 1: Write the failing model tests** + +Append to `divelogs_models_test.dart`: + +```dart +group('DivelogsDivelistEntry', () { + test('parses id, date/time (wall-clock UTC), duration, maxdepth', () { + final entry = DivelogsDivelistEntry.fromJson({ + 'id': 4711, + 'date': '2022-09-03', + 'time': '14:42:00', + 'duration': 2808, + 'maxdepth': 12, + })!; + expect(entry.id, '4711'); + expect(entry.dateTime, DateTime.utc(2022, 9, 3, 14, 42)); + expect(entry.durationSeconds, 2808); + expect(entry.maxDepth, 12.0); + }); + + test('tolerates missing duration and maxdepth', () { + final entry = DivelogsDivelistEntry.fromJson({ + 'id': '9', + 'date': '2022-09-03', + 'time': '14:42:00', + })!; + expect(entry.durationSeconds, isNull); + expect(entry.maxDepth, isNull); + }); + + test('accepts a combined datetime field as fallback', () { + final entry = DivelogsDivelistEntry.fromJson({ + 'id': 9, + 'datetime': '2022-09-03 14:42:00', + })!; + expect(entry.dateTime, DateTime.utc(2022, 9, 3, 14, 42)); + }); + + test('returns null when id or date is unusable', () { + expect( + DivelogsDivelistEntry.fromJson({'date': '2022-09-03'}), + isNull, + ); + expect(DivelogsDivelistEntry.fromJson({'id': 1}), isNull); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/core/services/divelogs/divelogs_models_test.dart` +Expected: FAIL — `DivelogsDivelistEntry` undefined. + +- [ ] **Step 3: Implement the model** + +Append to `divelogs_models.dart`: + +```dart +/// One row of GET /divelist — the cheap compare key set. The endpoint's +/// exact shape is undocumented (spec open question 3), so parsing is +/// tolerant: unusable rows yield null and are counted, never thrown. +class DivelogsDivelistEntry { + final String id; + final DateTime dateTime; // wall-clock UTC, same convention as DivelogsDive + final int? durationSeconds; + final double? maxDepth; + + const DivelogsDivelistEntry({ + required this.id, + required this.dateTime, + this.durationSeconds, + this.maxDepth, + }); + + static DivelogsDivelistEntry? fromJson(Map json) { + final rawId = json['id'] ?? json['dive_id']; + if (rawId == null) return null; + + DateTime? dateTime; + final date = _asNonEmptyString(json['date']); + if (date != null) { + final time = _asNonEmptyString(json['time']) ?? '00:00:00'; + dateTime = DateTime.tryParse('${date}T${time}Z'); + } else { + final combined = _asNonEmptyString(json['datetime']); + if (combined != null) { + dateTime = DateTime.tryParse('${combined.replaceFirst(' ', 'T')}Z'); + } + } + if (dateTime == null) return null; + + return DivelogsDivelistEntry( + id: '$rawId', + dateTime: dateTime, + durationSeconds: _asInt(json['duration']), + maxDepth: _asDouble(json['maxdepth']), + ); + } +} + +class DivelogsDivelistResult { + final List entries; + final int skippedCount; + + const DivelogsDivelistResult({required this.entries, this.skippedCount = 0}); +} +``` + +- [ ] **Step 4: Run model tests to verify pass** + +Run: `flutter test test/core/services/divelogs/divelogs_models_test.dart` +Expected: PASS. + +- [ ] **Step 5: Write failing client tests** + +Append to `divelogs_api_client_test.dart` (reuse the existing `client(...)` helper): + +```dart +test('getDivelist parses array body and counts unusable rows', () async { + final api = client( + (req) async { + expect(req.url.path, '/api/divelist'); + return http.Response( + jsonEncode([ + {'id': 1, 'date': '2022-09-03', 'time': '10:00:00'}, + {'no_id': true}, + ]), + 200, + ); + }, + ); + final result = await api.getDivelist(); + expect(result.entries, hasLength(1)); + expect(result.entries.single.id, '1'); + expect(result.skippedCount, 1); +}); + +test('getDivelist tolerates object body with dives/divelist key', () async { + final api = client( + (req) async => http.Response( + jsonEncode({ + 'divelist': [ + {'id': 1, 'date': '2022-09-03', 'time': '10:00:00'}, + ], + }), + 200, + ), + ); + expect((await api.getDivelist()).entries, hasLength(1)); +}); + +test('postDives sends JSON array body with bearer header', () async { + late http.Request captured; + final api = client((req) async { + captured = req; + return http.Response('{"success": true}', 200); + }); + await api.postDives([ + {'date': '2022-09-03', 'time': '10:00:00', 'duration': 60, 'maxdepth': 5}, + ]); + expect(captured.method, 'POST'); + expect(captured.url.toString(), 'https://divelogs.de/api/dives'); + expect(captured.headers['Authorization'], 'Bearer t1'); + expect(captured.headers['Content-Type'], startsWith('application/json')); + final body = jsonDecode(captured.body) as List; + expect(body, hasLength(1)); +}); + +test('postDives retries once on 401 then succeeds', () async { + var calls = 0; + final api = client( + (req) async { + calls++; + if (req.headers['Authorization'] == 'Bearer t1') { + return http.Response('', 401); + } + return http.Response('{}', 200); + }, + tokens: ['t1', 't2'], + ); + await api.postDives([ + {'duration': 60}, + ]); + expect(calls, 2); +}); + +test('postDives throws DivelogsApiException on 400', () async { + final api = client((req) async => http.Response('bad', 400)); + expect( + () => api.postDives([{}]), + throwsA( + isA().having((e) => e.statusCode, 'status', 400), + ), + ); +}); +``` + +- [ ] **Step 6: Run to verify failure, then implement client methods** + +Run: `flutter test test/core/services/divelogs/divelogs_api_client_test.dart` — expect FAIL. Then in `divelogs_api_client.dart`: + +1. Generalize the request loop: rename `_get(String path)`'s body into + `_send(String path, {String method = 'GET', Object? jsonBody})` and keep + `_get` as `_send(path)`. The loop body changes only in how the request is + issued: + +```dart +Future _send( + String path, { + String method = 'GET', + Object? jsonBody, +}) async { + var authRetried = false; + while (true) { + final token = await _getBearerToken(); + final uri = _baseUri.replace(path: '${_baseUri.path}$path'); + final headers = { + 'Authorization': 'Bearer $token', + if (jsonBody != null) 'Content-Type': 'application/json', + }; + final http.Response response; + try { + response = method == 'POST' + ? await _http.post(uri, headers: headers, body: jsonEncode(jsonBody)) + : await _http.get(uri, headers: headers); + } on Exception { + throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + _onTokenRejected(); + if (!authRetried) { + authRetried = true; + continue; + } + throw const DivelogsApiException( + 401, + 'divelogs.de sign-in expired. Sign in again in Settings.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsApiException( + response.statusCode, + 'divelogs.de API error ${response.statusCode}', + ); + } + return response; + } +} + +Future _get(String path) => _send(path); +``` + +2. Add the two endpoints: + +```dart +Future getDivelist() async { + final response = await _get('/divelist'); + final decoded = _decode(response.body, '/divelist'); + final List rows; + if (decoded is List) { + rows = decoded; + } else if (decoded is Map && decoded['divelist'] is List) { + rows = decoded['divelist'] as List; + } else if (decoded is Map && decoded['dives'] is List) { + rows = decoded['dives'] as List; + } else { + throw const DivelogsApiException(0, 'Unexpected /divelist response'); + } + final entries = []; + var skipped = 0; + for (final row in rows) { + final entry = row is Map + ? DivelogsDivelistEntry.fromJson(Map.from(row)) + : null; + if (entry == null) { + skipped++; + } else { + entries.add(entry); + } + } + return DivelogsDivelistResult(entries: entries, skippedCount: skipped); +} + +/// Bulk-create dives (create-only; the caller chunks). +Future postDives(List> dives) async { + await _send('/dives', method: 'POST', jsonBody: dives); +} +``` + +- [ ] **Step 7: Run all divelogs service tests** + +Run: `flutter test test/core/services/divelogs/` +Expected: PASS (all Phase 1 tests still green plus the new ones). + +- [ ] **Step 8: Commit** + +```bash +dart format . +git add -A lib/core/services/divelogs test/core/services/divelogs +git commit -m "feat: add divelogs.de divelist and bulk dive-create endpoints" +``` + +--- + +### Task 2: `DivelogsExportMapper` — domain `Dive` to divelogs JSON + +**Files:** +- Create: `lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart` +- Test: `test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart` + +**Interfaces:** +- Consumes: domain `Dive`/`DiveTank`/`GasMix`/`DiveProfilePoint` (`lib/features/dive_log/domain/entities/dive.dart`), `Dive.effectiveEntryTime` (`DateTime`), `Dive.effectiveRuntime` (`Duration?`). +- Produces: `class DivelogsExportMapper { const DivelogsExportMapper(); Map? mapDive(Dive dive); }` — null when the API's mandatory fields (`date`/`time`/`duration`/`maxdepth`) cannot be produced. The lossy projection is intentional (spec: push path). + +Projection rules (all metric, matching the API schema): +- `date` = `yyyy-MM-dd`, `time` = `HH:mm:ss` from `dive.effectiveEntryTime` (wall-clock; format the UTC components directly, no timezone conversion). +- `duration` = `dive.effectiveRuntime?.inSeconds` — if null/zero, the dive is unmappable (return null). +- `maxdepth` = `dive.maxDepth ?? dive.calculateMaxDepthFromProfile()` — if null, return null. +- `meandepth` = `dive.avgDepth` when > 0. +- `sampledata`/`samplerate`: only when the profile has 2+ points AND the timestamp deltas are uniform (every `profile[i+1].timestamp - profile[i].timestamp` equals the first delta, delta > 0). Emit `samplerate` = delta and one entry per point: `{'d': depth, 't': temperature}` when the point has temperature, else the bare depth number. Non-uniform profiles omit sampledata entirely (divelogs assumes a fixed rate). +- `tanks` = one map per `DiveTank`: `o2`/`he` from `gasMix`, `start_pressure`/`end_pressure`/`vol`/`wp` from `startPressure`/`endPressure`/`volume`/`workingPressure` (each only when non-null and, for vol/wp, > 0), `tankname` from `name` when non-null. +- `buddy` = `dive.buddy` when non-null; `divesite` = `dive.site?.name`; `lat`/`lng` from `dive.site?.location ?? dive.entryLocation` (both coordinates or neither); `location` = site `country`/`region` joined with ", " (skipping nulls; omit when empty). +- `notes` = `dive.notes` when non-empty; `airtemp` = `dive.airTemp`; `depthtemp` = `dive.waterTemp`; `weights` = `dive.weightAmount` when > 0; `surface_interval` = `dive.surfaceInterval?.inSeconds` when > 0; `dc_model` = `dive.diveComputerModel`. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart'; + +void main() { + const mapper = DivelogsExportMapper(); + + Dive dive({ + Duration? runtime = const Duration(seconds: 2808), + double? maxDepth = 18.5, + List profile = const [], + }) => Dive( + id: 'd1', + dateTime: DateTime.utc(2022, 9, 3, 14, 42, 30), + entryTime: DateTime.utc(2022, 9, 3, 14, 42, 30), + runtime: runtime, + maxDepth: maxDepth, + avgDepth: 7.9, + notes: 'nice dive', + buddy: 'Buddy', + airTemp: 28, + waterTemp: 21, + weightAmount: 4, + surfaceInterval: const Duration(hours: 1), + diveComputerModel: 'Suunto D6', + profile: profile, + site: const DiveSite( + id: 's1', + name: 'Shinenead', + location: GeoPoint(24.6, 35.1), + country: 'Egypt', + region: 'Red Sea', + ), + tanks: const [ + DiveTank( + id: 't1', + volume: 12, + workingPressure: 200, + startPressure: 214.5, + endPressure: 103, + gasMix: GasMix(o2: 28), + name: 'Main', + ), + ], + ); + + test('maps mandatory and optional fields to API schema keys', () { + final json = mapper.mapDive(dive())!; + expect(json['date'], '2022-09-03'); + expect(json['time'], '14:42:30'); + expect(json['duration'], 2808); + expect(json['maxdepth'], 18.5); + expect(json['meandepth'], 7.9); + expect(json['buddy'], 'Buddy'); + expect(json['divesite'], 'Shinenead'); + expect(json['lat'], 24.6); + expect(json['lng'], 35.1); + expect(json['location'], 'Egypt, Red Sea'); + expect(json['notes'], 'nice dive'); + expect(json['airtemp'], 28); + expect(json['depthtemp'], 21); + expect(json['weights'], 4); + expect(json['surface_interval'], 3600); + expect(json['dc_model'], 'Suunto D6'); + final tank = (json['tanks'] as List).single as Map; + expect(tank['o2'], 28.0); + expect(tank['he'], 0.0); + expect(tank['start_pressure'], 214.5); + expect(tank['end_pressure'], 103); + expect(tank['vol'], 12); + expect(tank['wp'], 200); + expect(tank['tankname'], 'Main'); + }); + + test('emits uniform profiles as sampledata with samplerate', () { + final json = mapper.mapDive( + dive( + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 1, temperature: 13), + DiveProfilePoint(timestamp: 10, depth: 10), + DiveProfilePoint(timestamp: 20, depth: 5), + ], + ), + )!; + expect(json['samplerate'], 10); + final samples = json['sampledata'] as List; + expect(samples[0], {'d': 1.0, 't': 13.0}); + expect(samples[1], 10.0); + expect(samples[2], 5.0); + }); + + test('omits sampledata for non-uniform profiles', () { + final json = mapper.mapDive( + dive( + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 1), + DiveProfilePoint(timestamp: 7, depth: 10), + DiveProfilePoint(timestamp: 20, depth: 5), + ], + ), + )!; + expect(json.containsKey('sampledata'), isFalse); + expect(json.containsKey('samplerate'), isFalse); + }); + + test('returns null when duration or maxdepth cannot be produced', () { + expect(mapper.mapDive(dive(runtime: null)), isNull); + expect(mapper.mapDive(dive(maxDepth: null)), isNull); + }); + + test('falls back to profile max depth when maxDepth is null', () { + final json = mapper.mapDive( + dive( + maxDepth: null, + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 3), + DiveProfilePoint(timestamp: 10, depth: 9.5), + ], + ), + ); + expect(json, isNotNull); + expect(json!['maxdepth'], 9.5); + }); +} +``` + +(Adjust the `Dive`/`DiveSite` fixture construction if any named parameter differs — both constructors were verified in Phase 1; `DiveSite` requires `id` and `name`, `GeoPoint` is positional `(lat, lng)`.) + +Note: `dive(runtime: null)` still has an empty profile and no `bottomTime`/`exitTime`, so `effectiveRuntime` is null — that is what makes it unmappable. + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart` +Expected: FAIL — mapper missing. + +- [ ] **Step 3: Implement the mapper** + +```dart +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +/// Projects a domain Dive onto the divelogs.de dive JSON schema. +/// +/// Lossy by design (spec: push path): one profile channel, tanks, site +/// name + GPS, buddy string, notes, temps, weights. Returns null when the +/// API's mandatory fields (date/time/duration/maxdepth) cannot be +/// produced; the caller reports such dives as skipped. +class DivelogsExportMapper { + const DivelogsExportMapper(); + + Map? mapDive(Dive dive) { + final entry = dive.effectiveEntryTime; + final durationSeconds = dive.effectiveRuntime?.inSeconds; + final maxDepth = dive.maxDepth ?? dive.calculateMaxDepthFromProfile(); + if (durationSeconds == null || durationSeconds <= 0 || maxDepth == null) { + return null; + } + + String two(int v) => v.toString().padLeft(2, '0'); + final json = { + 'date': '${entry.year}-${two(entry.month)}-${two(entry.day)}', + 'time': '${two(entry.hour)}:${two(entry.minute)}:${two(entry.second)}', + 'duration': durationSeconds, + 'maxdepth': maxDepth, + }; + + final avg = dive.avgDepth; + if (avg != null && avg > 0) json['meandepth'] = avg; + if (dive.buddy != null) json['buddy'] = dive.buddy; + final siteName = dive.site?.name; + if (siteName != null && siteName.isNotEmpty) json['divesite'] = siteName; + final location = dive.site?.location ?? dive.entryLocation; + if (location != null) { + json['lat'] = location.latitude; + json['lng'] = location.longitude; + } + final locality = [ + dive.site?.country, + dive.site?.region, + ].whereType().where((s) => s.isNotEmpty).join(', '); + if (locality.isNotEmpty) json['location'] = locality; + if (dive.notes.isNotEmpty) json['notes'] = dive.notes; + if (dive.airTemp != null) json['airtemp'] = dive.airTemp; + if (dive.waterTemp != null) json['depthtemp'] = dive.waterTemp; + final weight = dive.weightAmount; + if (weight != null && weight > 0) json['weights'] = weight; + final surfaceInterval = dive.surfaceInterval?.inSeconds; + if (surfaceInterval != null && surfaceInterval > 0) { + json['surface_interval'] = surfaceInterval; + } + if (dive.diveComputerModel != null) { + json['dc_model'] = dive.diveComputerModel; + } + + final tanks = dive.tanks + .map( + (t) => { + 'o2': t.gasMix.o2, + 'he': t.gasMix.he, + if (t.startPressure != null) 'start_pressure': t.startPressure, + if (t.endPressure != null) 'end_pressure': t.endPressure, + if (t.volume != null && t.volume! > 0) 'vol': t.volume, + if (t.workingPressure != null && t.workingPressure! > 0) + 'wp': t.workingPressure, + if (t.name != null && t.name!.isNotEmpty) 'tankname': t.name, + }, + ) + .toList(); + if (tanks.isNotEmpty) json['tanks'] = tanks; + + _addProfile(json, dive.profile); + return json; + } + + /// divelogs sampledata assumes one fixed sample rate, so only uniform + /// profiles are exported; anything else is omitted rather than distorted. + void _addProfile(Map json, List profile) { + if (profile.length < 2) return; + final delta = profile[1].timestamp - profile[0].timestamp; + if (delta <= 0) return; + for (var i = 1; i < profile.length; i++) { + if (profile[i].timestamp - profile[i - 1].timestamp != delta) return; + } + json['samplerate'] = delta; + json['sampledata'] = [ + for (final point in profile) + if (point.temperature != null) + {'d': point.depth, 't': point.temperature} + else + point.depth, + ]; + } +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `flutter test test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib/features/divelogs_sync test/features/divelogs_sync +git commit -m "feat: project domain dives onto the divelogs.de push schema" +``` + +--- + +### Task 3: `DivelogsSyncPlanner` — the two-way diff + +**Files:** +- Create: `lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart` +- Test: `test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart` + +**Interfaces:** +- Consumes: `DivelogsDivelistEntry` (Task 1), `DiveSummary` (`lib/features/dive_log/domain/entities/dive_summary.dart` — fields `id`, `diveNumber`, `name`, `dateTime`, `entryTime`, `maxDepth`, `bottomTime`, `runtime`), `DiveMatcher` (`lib/features/dive_import/domain/services/dive_matcher.dart`). +- Produces: + - `class DivelogsSyncPlan { final List pullCandidates; final List pushCandidates; final int matchedCount; }` + - `class DivelogsSyncPlanner { const DivelogsSyncPlanner({DiveMatcher matcher = const DiveMatcher()}); DivelogsSyncPlan plan({required List remote, required List local}); }` — pure function, no I/O. + +Matching rules (deterministic, documented in code): +- Hard time gate: a remote entry and local summary can only match when their wall-clock times differ by at most 15 minutes (`DiveMatcher`'s zero band). Local time = `entryTime ?? dateTime`; local duration = `runtime ?? bottomTime`. +- When BOTH sides have depth and duration, score with `matcher.calculateMatchScore` and require `matcher.isPossibleDuplicate(score)` (>= 0.5). +- When either side lacks depth or duration (unconfirmed `/divelist` shape), a time-gate pass alone is a match — degraded but safe, since 15 minutes of overlap on the same account almost always means the same dive. +- One-to-one greedy matching: process remote entries in ascending time order; each takes its best-scoring (or nearest-in-time, for degraded matches) unmatched local summary. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_sync_planner.dart'; + +void main() { + const planner = DivelogsSyncPlanner(); + + DivelogsDivelistEntry remote( + String id, + DateTime at, { + int? duration = 2808, + double? depth = 12, + }) => DivelogsDivelistEntry( + id: id, + dateTime: at, + durationSeconds: duration, + maxDepth: depth, + ); + + DiveSummary local( + String id, + DateTime at, { + Duration? runtime = const Duration(seconds: 2808), + double? depth = 12, + }) => DiveSummary( + id: id, + dateTime: at, + entryTime: at, + runtime: runtime, + maxDepth: depth, + isFavorite: false, + diveTypeIds: const [], + tags: const [], + sortTimestamp: at.millisecondsSinceEpoch, + ); + + final t0 = DateTime.utc(2022, 9, 3, 14, 42); + + test('matched pairs are neither pulled nor pushed', () { + final plan = planner.plan( + remote: [remote('r1', t0)], + local: [local('l1', t0)], + ); + expect(plan.pullCandidates, isEmpty); + expect(plan.pushCandidates, isEmpty); + expect(plan.matchedCount, 1); + }); + + test('remote-only dives are pull candidates, local-only are push', () { + final plan = planner.plan( + remote: [ + remote('r1', t0), + remote('r2', t0.add(const Duration(days: 1))), + ], + local: [ + local('l1', t0), + local('l2', t0.add(const Duration(days: 2))), + ], + ); + expect(plan.pullCandidates.map((e) => e.id), ['r2']); + expect(plan.pushCandidates.map((s) => s.id), ['l2']); + expect(plan.matchedCount, 1); + }); + + test('time gate: 20 minutes apart is not a match', () { + final plan = planner.plan( + remote: [remote('r1', t0)], + local: [local('l1', t0.add(const Duration(minutes: 20)))], + ); + expect(plan.pullCandidates, hasLength(1)); + expect(plan.pushCandidates, hasLength(1)); + }); + + test('same time but wildly different depth/duration is not a match', () { + final plan = planner.plan( + remote: [remote('r1', t0, duration: 2808, depth: 40)], + local: [local('l1', t0, runtime: const Duration(minutes: 5), depth: 3)], + ); + expect(plan.pullCandidates, hasLength(1)); + expect(plan.pushCandidates, hasLength(1)); + }); + + test('degraded match: divelist without depth/duration matches on time', () { + final plan = planner.plan( + remote: [remote('r1', t0, duration: null, depth: null)], + local: [local('l1', t0.add(const Duration(minutes: 5)))], + ); + expect(plan.pullCandidates, isEmpty); + expect(plan.pushCandidates, isEmpty); + expect(plan.matchedCount, 1); + }); + + test('one-to-one: a single local dive cannot match two remote entries', () { + final plan = planner.plan( + remote: [remote('r1', t0), remote('r2', t0.add(const Duration(minutes: 3)))], + local: [local('l1', t0)], + ); + expect(plan.matchedCount, 1); + expect(plan.pullCandidates, hasLength(1)); + expect(plan.pushCandidates, isEmpty); + }); +} +``` + +(If `DiveSummary`'s constructor requires additional parameters, supply the minimal defaults its declaration shows — it is a plain data class at `dive_summary.dart:11-56`.) + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart` +Expected: FAIL — planner missing. + +- [ ] **Step 3: Implement the planner** + +```dart +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; + +/// Result of comparing the remote divelist with local dive summaries. +class DivelogsSyncPlan { + final List pullCandidates; + final List pushCandidates; + final int matchedCount; + + const DivelogsSyncPlan({ + required this.pullCandidates, + required this.pushCandidates, + required this.matchedCount, + }); +} + +/// Stateless two-way diff for the create-only sync model (spec: sync +/// engine). Matching is time-gated (15 min, DiveMatcher's zero band) with +/// depth/duration refinement when both sides carry them; the undocumented +/// /divelist shape may omit depth/duration, in which case the time gate +/// alone decides (degraded but safe on a single user's account). +class DivelogsSyncPlanner { + const DivelogsSyncPlanner({this.matcher = const DiveMatcher()}); + + final DiveMatcher matcher; + + static const Duration _timeGate = Duration(minutes: 15); + + DivelogsSyncPlan plan({ + required List remote, + required List local, + }) { + final sortedRemote = [...remote] + ..sort((a, b) => a.dateTime.compareTo(b.dateTime)); + final unmatchedLocal = [...local]; + final pull = []; + var matched = 0; + + for (final entry in sortedRemote) { + DiveSummary? best; + var bestKey = double.negativeInfinity; + for (final summary in unmatchedLocal) { + final key = _matchKey(entry, summary); + if (key != null && key > bestKey) { + best = summary; + bestKey = key; + } + } + if (best != null) { + unmatchedLocal.remove(best); + matched++; + } else { + pull.add(entry); + } + } + + return DivelogsSyncPlan( + pullCandidates: pull, + pushCandidates: unmatchedLocal, + matchedCount: matched, + ); + } + + /// Returns a comparable match quality (higher is better), or null when + /// the pair does not match. + double? _matchKey(DivelogsDivelistEntry entry, DiveSummary summary) { + final localTime = summary.entryTime ?? summary.dateTime; + final timeDiff = entry.dateTime.difference(localTime).abs(); + if (timeDiff > _timeGate) return null; + + final localDuration = summary.runtime ?? summary.bottomTime; + final hasFullData = + entry.durationSeconds != null && + entry.maxDepth != null && + localDuration != null && + summary.maxDepth != null; + if (!hasFullData) { + // Degraded: time-gate only. Rank by time proximity below any real + // score so scored matches win when available. + return -timeDiff.inSeconds.toDouble() / _timeGate.inSeconds; + } + + final score = matcher.calculateMatchScore( + wearableStartTime: entry.dateTime, + wearableMaxDepth: entry.maxDepth!, + wearableDurationSeconds: entry.durationSeconds!, + existingStartTime: localTime, + existingMaxDepth: summary.maxDepth!, + existingDurationSeconds: localDuration.inSeconds, + ); + return matcher.isPossibleDuplicate(score) ? score : null; + } +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `flutter test test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib/features/divelogs_sync test/features/divelogs_sync +git commit -m "feat: add stateless two-way divelogs.de sync planner" +``` + +--- + +### Task 4: `DivelogsPushService` — chunked create-only push + +**Files:** +- Create: `lib/features/divelogs_sync/domain/services/divelogs_push_service.dart` +- Test: `test/features/divelogs_sync/domain/services/divelogs_push_service_test.dart` + +**Interfaces:** +- Consumes: `DivelogsApiClient.postDives` (Task 1), `DivelogsExportMapper` (Task 2), domain `Dive`. +- Produces: + - `class DivelogsPushResult { final int pushed; final int skippedUnmappable; final String? error; bool get failed => error != null; }` + - `class DivelogsPushService { DivelogsPushService({required DivelogsApiClient api, DivelogsExportMapper mapper = const DivelogsExportMapper(), int chunkSize = 50, Future Function(Duration)? delay}); Future push(List dives, {void Function(int done, int total)? onProgress}); }` +- Behavior: maps all dives (unmappable ones counted, never sent); sends chunks of `chunkSize` via `postDives` with a 200 ms courtesy delay between chunks (injectable `delay` for tests, default `Future.delayed`); `onProgress(done, total)` after each chunk (counts mapped dives); a `DivelogsApiException` stops the push and reports how many were already pushed plus the error message — no rollback (create-only + stateless compare make re-runs converge). + +- [ ] **Step 1: Write the failing test** + +```dart +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/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_push_service.dart'; + +void main() { + Dive dive(int n, {Duration? runtime = const Duration(minutes: 45)}) => Dive( + id: 'd$n', + dateTime: DateTime.utc(2022, 9, n + 1, 10), + entryTime: DateTime.utc(2022, 9, n + 1, 10), + runtime: runtime, + maxDepth: 10.0 + n, + ); + + DivelogsApiClient api(Future Function(http.Request) handler) => + DivelogsApiClient( + getBearerToken: () async => 't', + onTokenRejected: () {}, + httpClient: MockClient(handler), + ); + + DivelogsPushService service( + DivelogsApiClient client, { + int chunkSize = 2, + }) => DivelogsPushService( + api: client, + chunkSize: chunkSize, + delay: (_) async {}, + ); + + test('chunks dives and reports progress', () async { + final batches = []; + final progress = <(int, int)>[]; + final result = await service( + api((req) async { + batches.add((jsonDecode(req.body) as List).length); + return http.Response('{}', 200); + }), + ).push( + [dive(1), dive(2), dive(3)], + onProgress: (done, total) => progress.add((done, total)), + ); + expect(batches, [2, 1]); + expect(progress, [(2, 3), (3, 3)]); + expect(result.pushed, 3); + expect(result.skippedUnmappable, 0); + expect(result.failed, isFalse); + }); + + test('unmappable dives are counted and not sent', () async { + var sent = 0; + final result = await service( + api((req) async { + sent += (jsonDecode(req.body) as List).length; + return http.Response('{}', 200); + }), + ).push([dive(1), dive(2, runtime: null)]); + expect(sent, 1); + expect(result.pushed, 1); + expect(result.skippedUnmappable, 1); + }); + + test('a failed chunk stops the push and reports partial progress', + () async { + var call = 0; + final result = await service( + api((req) async { + call++; + return call == 1 ? http.Response('{}', 200) : http.Response('', 500); + }), + ).push([dive(1), dive(2), dive(3)]); + expect(result.pushed, 2); + expect(result.failed, isTrue); + expect(result.error, contains('500')); + }); + + test('empty mapped list makes no network calls', () async { + final result = await service( + api((req) async => fail('no call expected')), + ).push([dive(1, runtime: null)]); + expect(result.pushed, 0); + expect(result.skippedUnmappable, 1); + }); +} +``` + +- [ ] **Step 2: Run to verify failure, then implement** + +Run: `flutter test test/features/divelogs_sync/domain/services/divelogs_push_service_test.dart` — expect FAIL. Then: + +```dart +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart'; + +class DivelogsPushResult { + final int pushed; + final int skippedUnmappable; + final String? error; + + const DivelogsPushResult({ + required this.pushed, + required this.skippedUnmappable, + this.error, + }); + + bool get failed => error != null; +} + +/// Create-only bulk push. A failure stops the run and reports partial +/// progress; no rollback is needed because the next compare simply matches +/// whatever was already created (stateless model, spec: push path). +class DivelogsPushService { + DivelogsPushService({ + required DivelogsApiClient api, + this.mapper = const DivelogsExportMapper(), + this.chunkSize = 50, + Future Function(Duration)? delay, + }) : _api = api, + _delay = delay ?? Future.delayed; + + final DivelogsApiClient _api; + final DivelogsExportMapper mapper; + final int chunkSize; + final Future Function(Duration) _delay; + + static const Duration _interChunkDelay = Duration(milliseconds: 200); + + Future push( + List dives, { + void Function(int done, int total)? onProgress, + }) async { + final mapped = >[]; + var skipped = 0; + for (final dive in dives) { + final json = mapper.mapDive(dive); + if (json == null) { + skipped++; + } else { + mapped.add(json); + } + } + + var pushed = 0; + for (var start = 0; start < mapped.length; start += chunkSize) { + if (start > 0) await _delay(_interChunkDelay); + final chunk = mapped.sublist( + start, + start + chunkSize > mapped.length ? mapped.length : start + chunkSize, + ); + try { + await _api.postDives(chunk); + } on DivelogsApiException catch (e) { + return DivelogsPushResult( + pushed: pushed, + skippedUnmappable: skipped, + error: e.message, + ); + } + pushed += chunk.length; + onProgress?.call(pushed, mapped.length); + } + return DivelogsPushResult(pushed: pushed, skippedUnmappable: skipped); + } +} +``` + +- [ ] **Step 3: Run tests to verify pass** + +Run: `flutter test test/features/divelogs_sync/domain/services/divelogs_push_service_test.dart` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +dart format . +git add -A lib/features/divelogs_sync test/features/divelogs_sync +git commit -m "feat: add chunked create-only divelogs.de push service" +``` + +--- + +### Task 5: Sync page UI + routing from the Connected Accounts roster + +**Files:** +- Create: `lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart` +- Modify: `lib/core/router/app_router.dart` (settings routes, next to `connected-accounts`) +- Modify: `lib/features/settings/presentation/pages/connected_accounts_page.dart` (`_AccountTile` gains `onTap` for divelogs) +- Modify: `lib/l10n/arb/app_en.arb` + all 10 non-English arb files +- Test: `test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart` + +**Interfaces:** +- Consumes: Tasks 1–4 (`getDivelist`, `DivelogsSyncPlanner`, `DivelogsPushService`), Phase 1's `DivelogsAccountAdapter.authManagerFor`, `divelogsHttpClientProvider`, `connectedAccountsRepositoryProvider`, `accountProviderRegistryProvider`, `diveRepositoryProvider` (`getDiveSummaries({diverId, limit})`, `getDivesByIds(List)`), `currentDiverProvider`. +- Produces: route `/settings/divelogs-sync` (name `divelogsSync`); the sync page. + +Page behavior (a `ConsumerStatefulWidget`, phase enum like `DivelogsFetchStep`): +1. **Load**: `getByKind(AccountKind.divelogs)`. No account → message with a button routing to `/transfer/divelogs-import` (the connect flow lives in the wizard). Account with `needsSignIn` status → same routing. Account signed in → show a Compare button (and run compare automatically on first load). +2. **Compare**: `getDivelist()` + `getDiveSummaries(diverId: account.diverId ?? currentDiver?.id, limit: 1000000)` → `DivelogsSyncPlanner().plan(...)`. Uses the same diver-binding guard as Phase 1: if `account.diverId` differs from the active diver, show the wrong-diver message (reuse `divelogs_fetch_wrongDiver`). +3. **Result view**: three summary rows — matched count, "Pull: N new from divelogs.de" with a button that routes to `/transfer/divelogs-import` (the wizard IS the pull review; its dedup makes the counts consistent), and "Push: M dives not on divelogs.de" with a checkbox list (`CheckboxListTile` per push candidate: dive number, name/`effectiveName` fallback to date, formatted date) all checked by default, plus a "Push selected" button. + - Note: the spec sketches per-dive toggles for both directions on this page; pull toggles are intentionally delegated to the wizard's existing review step to avoid duplicating selection UI (deviation recorded in the spec's sync-page section intent — compare, then review — which this preserves). +4. **Push**: `getDivesByIds(selectedIds)` → `DivelogsPushService(api: ...).push(dives, onProgress: ...)` with a linear progress indicator; on completion show "Pushed N dives" (+ "M could not be converted" when `skippedUnmappable > 0`), and on `result.failed` show the error with a Retry that re-runs compare first (stateless convergence). After any push, automatically re-run compare. +5. All strings via `context.l10n`; dates formatted with the existing localization utilities used by `DiveSummary` lists (check `dive_list_item` for the date format helper; a plain `MaterialLocalizations.of(context).formatShortDate` is acceptable). + +New l10n keys (en values; translate into all 10 non-English locales, mirroring Phase 1's script approach): + +```json +"divelogsSync_title": "divelogs.de Sync", +"divelogsSync_notConnected": "No divelogs.de account is connected yet. Start an import to sign in.", +"divelogsSync_openImport": "Open divelogs.de import", +"divelogsSync_compare": "Compare", +"divelogsSync_comparing": "Comparing with divelogs.de...", +"divelogsSync_matched": "{count} dives already in sync", +"@divelogsSync_matched": { "placeholders": { "count": { "type": "int" } } }, +"divelogsSync_pullHeader": "Pull: {count} new on divelogs.de", +"@divelogsSync_pullHeader": { "placeholders": { "count": { "type": "int" } } }, +"divelogsSync_pullReview": "Review and pull in the import wizard", +"divelogsSync_pushHeader": "Push: {count} dives not on divelogs.de", +"@divelogsSync_pushHeader": { "placeholders": { "count": { "type": "int" } } }, +"divelogsSync_pushSelected": "Push selected", +"divelogsSync_pushing": "Pushing dives to divelogs.de...", +"divelogsSync_pushDone": "Pushed {count} dives to divelogs.de.", +"@divelogsSync_pushDone": { "placeholders": { "count": { "type": "int" } } }, +"divelogsSync_pushSkipped": "{count} dives could not be converted and were skipped.", +"@divelogsSync_pushSkipped": { "placeholders": { "count": { "type": "int" } } }, +"divelogsSync_pushFailedPartial": "Push stopped after {count} dives: {error}", +"@divelogsSync_pushFailedPartial": { "placeholders": { "count": { "type": "int" }, "error": { "type": "String" } } }, +"divelogsSync_nothingToSync": "Everything is in sync." +``` + +Router addition (inside the `/settings` routes, next to `connected-accounts`): + +```dart +GoRoute( + path: 'divelogs-sync', + name: 'divelogsSync', + builder: (context, state) => const DivelogsSyncPage(), +), +``` + +`_AccountTile` addition in `connected_accounts_page.dart` — give the `ListTile` an `onTap` that is non-null only for divelogs: + +```dart +onTap: account.kind == AccountKind.divelogs + ? () => context.push('/settings/divelogs-sync') + : null, +``` + +- [ ] **Step 1: Write the failing widget test** + +Model the harness on `test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart` (same overrides: `sharedPreferencesProvider`, `accountCredentialsStoreProvider`, `divelogsHttpClientProvider`, `allDiversProvider`, `currentDiverProvider`; same `setUpTestDatabase` + `tester.runAsync` + pinned `locale: Locale('en')` + l10n delegates). Cover: + +```dart +testWidgets('shows connect prompt when no account exists', (tester) async { + // pump DivelogsSyncPage with no account rows + // expect find.text('No divelogs.de account is connected yet. Start an import to sign in.') +}); + +testWidgets('compare renders pull/push/matched sections', (tester) async { + // seed: create account via connectedAccountsRepositoryProvider + // (kind: divelogs, diverId: 'diver-1') and write DivelogsCredentials + // with a bearerToken so status == signedIn and no login call happens + // MockClient: GET /api/divelist returns two remote entries, one matching + // a seeded local dive (insert via diveRepositoryProvider.createDive with + // entryTime/runtime/maxDepth), one new + // expect pull header contains '1', push header contains the count of + // unmatched local dives, matched row contains '1' +}); + +testWidgets('push posts selected dives and reports the count', (tester) async { + // same seeding; MockClient handles POST /api/dives returning 200 and + // captures the body; tap 'Push selected'; expect the POST body length + // equals the push-candidate count and 'Pushed 1 dives' (l10n plural + // simple form) appears; expect a second GET /api/divelist (auto re-compare) +}); +``` + +Write these three tests in full (arrange/act/assert as sketched — the seeding and MockClient plumbing are mechanical repetitions of the Phase 1 widget test; `diveRepositoryProvider.createDive(Dive(...))` inserts local dives). + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart` +Expected: FAIL — page missing. + +- [ ] **Step 3: Implement the page, route, tile tap, and l10n keys** + +Implement `DivelogsSyncPage` per the behavior spec above (phases: `loading`, `notConnected`, `wrongDiver`, `comparing`, `plan`, `pushing`, `error`). Structure it like `DivelogsFetchStep`: a private phase enum, `_compare()` and `_push()` async methods guarded with `if (!mounted) return;`, services constructed exactly as the fetch step does: + +```dart +final adapter = ref.read(accountProviderRegistryProvider) + .adapterFor(AccountKind.divelogs) as DivelogsAccountAdapter; +final manager = adapter.authManagerFor(account); +final api = DivelogsApiClient( + getBearerToken: manager.getToken, + onTokenRejected: manager.invalidateToken, + httpClient: ref.read(divelogsHttpClientProvider), +); +``` + +Add the arb keys to `app_en.arb` and translated equivalents to all 10 non-English arb files (same insertion-script approach as Phase 1), run `flutter gen-l10n`, add the `GoRoute`, and the `_AccountTile.onTap`. + +- [ ] **Step 4: Run tests, analyze, and the existing suites this touches** + +Run: `flutter test test/features/divelogs_sync test/features/import_wizard test/core/services/divelogs test/l10n && flutter analyze` +Expected: all PASS, no analyze issues. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: add divelogs.de sync page with compare and chunked push" +``` + +--- + +### Task 6: Verification sweep + +**Files:** none new. + +- [ ] **Step 1: Format and analyze** + +Run: `dart format . && flutter analyze` +Expected: no changes, no issues. + +- [ ] **Step 2: Run the touched surface** + +```bash +flutter test \ + test/core/services/divelogs \ + test/features/divelogs_sync \ + test/features/import_wizard \ + test/features/universal_import/data/services \ + test/core/services/accounts +``` +Expected: all PASS. + +- [ ] **Step 3: Full suite in the worktree** + +Run: `flutter test` (background it; ~4 minutes). Fix anything red — check for exact-latest tripwires if any schema-adjacent constant changed (none should; Phase 2 has NO schema migration). + +- [ ] **Step 4: Manual smoke note + commit any fixes** + +macOS smoke (sign in, compare, push a dive, re-compare shows it matched) remains pending on a real divelogs.de account — record in the PR description. Commit fixes if any: + +```bash +dart format . +git add -A +git commit -m "test: divelogs.de phase 2 verification fixes" +``` + +(Do not push or open a PR — that is a separate, user-triggered step; note this branch stacks on PR #603.) + +--- + +## Deferred (do NOT build now) + +- Gear + certifications sync (Phase 3), pictures (Phase 4). +- `LogbookSyncCapable` members: still a marker. The sync page constructs services directly (same as the Phase 1 fetch step); promote shared construction into the capability interface only when a second logbook service exists (YAGNI). +- Divelist-shape refinements once Rainer answers spec open question 3 (the tolerant parser + degraded matching cover the unknowns until then). + +## Open assumptions (confirm with Rainer, do not block) + +- `/divelist` rows carry `id` + `date`/`time` (or a combined `datetime`), optionally `duration`/`maxdepth`. +- `POST /dives` accepts up to 50 dives per request. +- `sampledata` on POST assumes one fixed `samplerate` per dive. From 3194525ad44ea08fef7bc3552a0d294ad09db0ba Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:00:41 -0400 Subject: [PATCH 16/35] feat: add divelogs.de divelist and bulk dive-create endpoints --- .../divelogs/divelogs_api_client.dart | 58 +++++++++++++-- .../services/divelogs/divelogs_models.dart | 49 +++++++++++++ .../divelogs/divelogs_api_client_test.dart | 73 +++++++++++++++++++ .../divelogs/divelogs_models_test.dart | 39 ++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) diff --git a/lib/core/services/divelogs/divelogs_api_client.dart b/lib/core/services/divelogs/divelogs_api_client.dart index d71cfb0d54..e00c217891 100644 --- a/lib/core/services/divelogs/divelogs_api_client.dart +++ b/lib/core/services/divelogs/divelogs_api_client.dart @@ -69,6 +69,40 @@ class DivelogsApiClient { return DivelogsDivesResult(dives: dives, skippedCount: skipped); } + /// Fetches the short divelist used for the cheap two-way compare. + Future getDivelist() async { + final response = await _get('/divelist'); + final decoded = _decode(response.body, '/divelist'); + final List rows; + if (decoded is List) { + rows = decoded; + } else if (decoded is Map && decoded['divelist'] is List) { + rows = decoded['divelist'] as List; + } else if (decoded is Map && decoded['dives'] is List) { + rows = decoded['dives'] as List; + } else { + throw const DivelogsApiException(0, 'Unexpected /divelist response'); + } + final entries = []; + var skipped = 0; + for (final row in rows) { + final entry = row is Map + ? DivelogsDivelistEntry.fromJson(Map.from(row)) + : null; + if (entry == null) { + skipped++; + } else { + entries.add(entry); + } + } + return DivelogsDivelistResult(entries: entries, skippedCount: skipped); + } + + /// Bulk-create dives (create-only; the caller chunks). + Future postDives(List> dives) async { + await _send('/dives', method: 'POST', jsonBody: dives); + } + /// Decodes a response body, converting FormatException (non-JSON error /// pages, proxy-injected HTML) into the retryable DivelogsApiException the /// UI already handles. @@ -80,16 +114,30 @@ class DivelogsApiClient { } } - Future _get(String path) async { + Future _get(String path) => _send(path); + + Future _send( + String path, { + String method = 'GET', + Object? jsonBody, + }) async { var authRetried = false; while (true) { final token = await _getBearerToken(); + final uri = _baseUri.replace(path: '${_baseUri.path}$path'); + final headers = { + 'Authorization': 'Bearer $token', + if (jsonBody != null) 'Content-Type': 'application/json', + }; final http.Response response; try { - response = await _http.get( - _baseUri.replace(path: '${_baseUri.path}$path'), - headers: {'Authorization': 'Bearer $token'}, - ); + response = method == 'POST' + ? await _http.post( + uri, + headers: headers, + body: jsonEncode(jsonBody), + ) + : await _http.get(uri, headers: headers); } on Exception { throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); } diff --git a/lib/core/services/divelogs/divelogs_models.dart b/lib/core/services/divelogs/divelogs_models.dart index 385d95f3a8..18c8e869b5 100644 --- a/lib/core/services/divelogs/divelogs_models.dart +++ b/lib/core/services/divelogs/divelogs_models.dart @@ -195,3 +195,52 @@ class DivelogsDivesResult { const DivelogsDivesResult({required this.dives, this.skippedCount = 0}); } + +/// One row of GET /divelist — the cheap compare key set. The endpoint's +/// exact shape is undocumented (spec open question 3), so parsing is +/// tolerant: unusable rows yield null and are counted, never thrown. +class DivelogsDivelistEntry { + final String id; + final DateTime dateTime; // wall-clock UTC, same convention as DivelogsDive + final int? durationSeconds; + final double? maxDepth; + + const DivelogsDivelistEntry({ + required this.id, + required this.dateTime, + this.durationSeconds, + this.maxDepth, + }); + + static DivelogsDivelistEntry? fromJson(Map json) { + final rawId = json['id'] ?? json['dive_id']; + if (rawId == null) return null; + + DateTime? dateTime; + final date = _asNonEmptyString(json['date']); + if (date != null) { + final time = _asNonEmptyString(json['time']) ?? '00:00:00'; + dateTime = DateTime.tryParse('${date}T${time}Z'); + } else { + final combined = _asNonEmptyString(json['datetime']); + if (combined != null) { + dateTime = DateTime.tryParse('${combined.replaceFirst(' ', 'T')}Z'); + } + } + if (dateTime == null) return null; + + return DivelogsDivelistEntry( + id: '$rawId', + dateTime: dateTime, + durationSeconds: _asInt(json['duration']), + maxDepth: _asDouble(json['maxdepth']), + ); + } +} + +class DivelogsDivelistResult { + final List entries; + final int skippedCount; + + const DivelogsDivelistResult({required this.entries, this.skippedCount = 0}); +} diff --git a/test/core/services/divelogs/divelogs_api_client_test.dart b/test/core/services/divelogs/divelogs_api_client_test.dart index b98b255890..de583c4524 100644 --- a/test/core/services/divelogs/divelogs_api_client_test.dart +++ b/test/core/services/divelogs/divelogs_api_client_test.dart @@ -106,4 +106,77 @@ void main() { final user = await api.getUser(); expect(user['username'], 'eric'); }); + + test('getDivelist parses array body and counts unusable rows', () async { + final api = client((req) async { + expect(req.url.path, '/api/divelist'); + return http.Response( + jsonEncode([ + {'id': 1, 'date': '2022-09-03', 'time': '10:00:00'}, + {'no_id': true}, + ]), + 200, + ); + }); + final result = await api.getDivelist(); + expect(result.entries, hasLength(1)); + expect(result.entries.single.id, '1'); + expect(result.skippedCount, 1); + }); + + test('getDivelist tolerates object body with dives/divelist key', () async { + final api = client( + (req) async => http.Response( + jsonEncode({ + 'divelist': [ + {'id': 1, 'date': '2022-09-03', 'time': '10:00:00'}, + ], + }), + 200, + ), + ); + expect((await api.getDivelist()).entries, hasLength(1)); + }); + + test('postDives sends JSON array body with bearer header', () async { + late http.Request captured; + final api = client((req) async { + captured = req; + return http.Response('{"success": true}', 200); + }); + await api.postDives([ + {'date': '2022-09-03', 'time': '10:00:00', 'duration': 60, 'maxdepth': 5}, + ]); + expect(captured.method, 'POST'); + expect(captured.url.toString(), 'https://divelogs.de/api/dives'); + expect(captured.headers['Authorization'], 'Bearer t1'); + expect(captured.headers['Content-Type'], startsWith('application/json')); + final body = jsonDecode(captured.body) as List; + expect(body, hasLength(1)); + }); + + test('postDives retries once on 401 then succeeds', () async { + var calls = 0; + final api = client((req) async { + calls++; + if (req.headers['Authorization'] == 'Bearer t1') { + return http.Response('', 401); + } + return http.Response('{}', 200); + }, tokens: ['t1', 't2']); + await api.postDives([ + {'duration': 60}, + ]); + expect(calls, 2); + }); + + test('postDives throws DivelogsApiException on 400', () async { + final api = client((req) async => http.Response('bad', 400)); + expect( + () => api.postDives([{}]), + throwsA( + isA().having((e) => e.statusCode, 'status', 400), + ), + ); + }); } diff --git a/test/core/services/divelogs/divelogs_models_test.dart b/test/core/services/divelogs/divelogs_models_test.dart index ed185ec378..45f1f84b81 100644 --- a/test/core/services/divelogs/divelogs_models_test.dart +++ b/test/core/services/divelogs/divelogs_models_test.dart @@ -98,4 +98,43 @@ void main() { expect(dive.surfaceIntervalSeconds, 3600); expect(dive.dcModel, 'Suunto D6'); }); + + group('DivelogsDivelistEntry', () { + test('parses id, date/time (wall-clock UTC), duration, maxdepth', () { + final entry = DivelogsDivelistEntry.fromJson({ + 'id': 4711, + 'date': '2022-09-03', + 'time': '14:42:00', + 'duration': 2808, + 'maxdepth': 12, + })!; + expect(entry.id, '4711'); + expect(entry.dateTime, DateTime.utc(2022, 9, 3, 14, 42)); + expect(entry.durationSeconds, 2808); + expect(entry.maxDepth, 12.0); + }); + + test('tolerates missing duration and maxdepth', () { + final entry = DivelogsDivelistEntry.fromJson({ + 'id': '9', + 'date': '2022-09-03', + 'time': '14:42:00', + })!; + expect(entry.durationSeconds, isNull); + expect(entry.maxDepth, isNull); + }); + + test('accepts a combined datetime field as fallback', () { + final entry = DivelogsDivelistEntry.fromJson({ + 'id': 9, + 'datetime': '2022-09-03 14:42:00', + })!; + expect(entry.dateTime, DateTime.utc(2022, 9, 3, 14, 42)); + }); + + test('returns null when id or date is unusable', () { + expect(DivelogsDivelistEntry.fromJson({'date': '2022-09-03'}), isNull); + expect(DivelogsDivelistEntry.fromJson({'id': 1}), isNull); + }); + }); } From dc705860faf8e1fba7b6c5f231f4af27ddd6a847 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:01:42 -0400 Subject: [PATCH 17/35] feat: project domain dives onto the divelogs.de push schema --- .../data/mappers/divelogs_export_mapper.dart | 94 +++++++++++++ .../mappers/divelogs_export_mapper_test.dart | 125 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart create mode 100644 test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart diff --git a/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart b/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart new file mode 100644 index 0000000000..95eba803e1 --- /dev/null +++ b/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart @@ -0,0 +1,94 @@ +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; + +/// Projects a domain Dive onto the divelogs.de dive JSON schema. +/// +/// Lossy by design (spec: push path): one profile channel, tanks, site +/// name + GPS, buddy string, notes, temps, weights. Returns null when the +/// API's mandatory fields (date/time/duration/maxdepth) cannot be +/// produced; the caller reports such dives as skipped. +class DivelogsExportMapper { + const DivelogsExportMapper(); + + Map? mapDive(Dive dive) { + final entry = dive.effectiveEntryTime; + final durationSeconds = dive.effectiveRuntime?.inSeconds; + final maxDepth = dive.maxDepth ?? dive.calculateMaxDepthFromProfile(); + if (durationSeconds == null || durationSeconds <= 0 || maxDepth == null) { + return null; + } + + String two(int v) => v.toString().padLeft(2, '0'); + final json = { + 'date': '${entry.year}-${two(entry.month)}-${two(entry.day)}', + 'time': '${two(entry.hour)}:${two(entry.minute)}:${two(entry.second)}', + 'duration': durationSeconds, + 'maxdepth': maxDepth, + }; + + final avg = dive.avgDepth; + if (avg != null && avg > 0) json['meandepth'] = avg; + if (dive.buddy != null) json['buddy'] = dive.buddy; + final siteName = dive.site?.name; + if (siteName != null && siteName.isNotEmpty) json['divesite'] = siteName; + final location = dive.site?.location ?? dive.entryLocation; + if (location != null) { + json['lat'] = location.latitude; + json['lng'] = location.longitude; + } + final locality = [ + dive.site?.country, + dive.site?.region, + ].whereType().where((s) => s.isNotEmpty).join(', '); + if (locality.isNotEmpty) json['location'] = locality; + if (dive.notes.isNotEmpty) json['notes'] = dive.notes; + if (dive.airTemp != null) json['airtemp'] = dive.airTemp; + if (dive.waterTemp != null) json['depthtemp'] = dive.waterTemp; + final weight = dive.weightAmount; + if (weight != null && weight > 0) json['weights'] = weight; + final surfaceInterval = dive.surfaceInterval?.inSeconds; + if (surfaceInterval != null && surfaceInterval > 0) { + json['surface_interval'] = surfaceInterval; + } + if (dive.diveComputerModel != null) { + json['dc_model'] = dive.diveComputerModel; + } + + final tanks = dive.tanks + .map( + (t) => { + 'o2': t.gasMix.o2, + 'he': t.gasMix.he, + if (t.startPressure != null) 'start_pressure': t.startPressure, + if (t.endPressure != null) 'end_pressure': t.endPressure, + if (t.volume != null && t.volume! > 0) 'vol': t.volume, + if (t.workingPressure != null && t.workingPressure! > 0) + 'wp': t.workingPressure, + if (t.name != null && t.name!.isNotEmpty) 'tankname': t.name, + }, + ) + .toList(); + if (tanks.isNotEmpty) json['tanks'] = tanks; + + _addProfile(json, dive.profile); + return json; + } + + /// divelogs sampledata assumes one fixed sample rate, so only uniform + /// profiles are exported; anything else is omitted rather than distorted. + void _addProfile(Map json, List profile) { + if (profile.length < 2) return; + final delta = profile[1].timestamp - profile[0].timestamp; + if (delta <= 0) return; + for (var i = 1; i < profile.length; i++) { + if (profile[i].timestamp - profile[i - 1].timestamp != delta) return; + } + json['samplerate'] = delta; + json['sampledata'] = [ + for (final point in profile) + if (point.temperature != null) + {'d': point.depth, 't': point.temperature} + else + point.depth, + ]; + } +} diff --git a/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart b/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart new file mode 100644 index 0000000000..5eadf631de --- /dev/null +++ b/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart'; + +void main() { + const mapper = DivelogsExportMapper(); + + Dive dive({ + Duration? runtime = const Duration(seconds: 2808), + double? maxDepth = 18.5, + List profile = const [], + }) => Dive( + id: 'd1', + dateTime: DateTime.utc(2022, 9, 3, 14, 42, 30), + entryTime: DateTime.utc(2022, 9, 3, 14, 42, 30), + runtime: runtime, + maxDepth: maxDepth, + avgDepth: 7.9, + notes: 'nice dive', + buddy: 'Buddy', + airTemp: 28, + waterTemp: 21, + weightAmount: 4, + surfaceInterval: const Duration(hours: 1), + diveComputerModel: 'Suunto D6', + profile: profile, + site: const DiveSite( + id: 's1', + name: 'Shinenead', + location: GeoPoint(24.6, 35.1), + country: 'Egypt', + region: 'Red Sea', + ), + tanks: const [ + DiveTank( + id: 't1', + volume: 12, + workingPressure: 200, + startPressure: 214.5, + endPressure: 103, + gasMix: GasMix(o2: 28), + name: 'Main', + ), + ], + ); + + test('maps mandatory and optional fields to API schema keys', () { + final json = mapper.mapDive(dive())!; + expect(json['date'], '2022-09-03'); + expect(json['time'], '14:42:30'); + expect(json['duration'], 2808); + expect(json['maxdepth'], 18.5); + expect(json['meandepth'], 7.9); + expect(json['buddy'], 'Buddy'); + expect(json['divesite'], 'Shinenead'); + expect(json['lat'], 24.6); + expect(json['lng'], 35.1); + expect(json['location'], 'Egypt, Red Sea'); + expect(json['notes'], 'nice dive'); + expect(json['airtemp'], 28); + expect(json['depthtemp'], 21); + expect(json['weights'], 4); + expect(json['surface_interval'], 3600); + expect(json['dc_model'], 'Suunto D6'); + final tank = (json['tanks'] as List).single as Map; + expect(tank['o2'], 28.0); + expect(tank['he'], 0.0); + expect(tank['start_pressure'], 214.5); + expect(tank['end_pressure'], 103); + expect(tank['vol'], 12); + expect(tank['wp'], 200); + expect(tank['tankname'], 'Main'); + }); + + test('emits uniform profiles as sampledata with samplerate', () { + final json = mapper.mapDive( + dive( + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 1, temperature: 13), + DiveProfilePoint(timestamp: 10, depth: 10), + DiveProfilePoint(timestamp: 20, depth: 5), + ], + ), + )!; + expect(json['samplerate'], 10); + final samples = json['sampledata'] as List; + expect(samples[0], {'d': 1.0, 't': 13.0}); + expect(samples[1], 10.0); + expect(samples[2], 5.0); + }); + + test('omits sampledata for non-uniform profiles', () { + final json = mapper.mapDive( + dive( + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 1), + DiveProfilePoint(timestamp: 7, depth: 10), + DiveProfilePoint(timestamp: 20, depth: 5), + ], + ), + )!; + expect(json.containsKey('sampledata'), isFalse); + expect(json.containsKey('samplerate'), isFalse); + }); + + test('returns null when duration or maxdepth cannot be produced', () { + expect(mapper.mapDive(dive(runtime: null)), isNull); + expect(mapper.mapDive(dive(maxDepth: null)), isNull); + }); + + test('falls back to profile max depth when maxDepth is null', () { + final json = mapper.mapDive( + dive( + maxDepth: null, + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 3), + DiveProfilePoint(timestamp: 10, depth: 9.5), + ], + ), + ); + expect(json, isNotNull); + expect(json!['maxdepth'], 9.5); + }); +} From 704bfc04cc2f048f779779068a01d93c31fc0676 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:03:01 -0400 Subject: [PATCH 18/35] feat: add stateless two-way divelogs.de sync planner --- .../services/divelogs_sync_planner.dart | 97 +++++++++++++++++ .../services/divelogs_sync_planner_test.dart | 100 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart create mode 100644 test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart diff --git a/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart b/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart new file mode 100644 index 0000000000..80aa16f3a0 --- /dev/null +++ b/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart @@ -0,0 +1,97 @@ +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; + +/// Result of comparing the remote divelist with local dive summaries. +class DivelogsSyncPlan { + final List pullCandidates; + final List pushCandidates; + final int matchedCount; + + const DivelogsSyncPlan({ + required this.pullCandidates, + required this.pushCandidates, + required this.matchedCount, + }); +} + +/// Stateless two-way diff for the create-only sync model (spec: sync +/// engine). Matching is time-gated (15 min, DiveMatcher's zero band) with +/// depth/duration refinement when both sides carry them; the undocumented +/// /divelist shape may omit depth/duration, in which case the time gate +/// alone decides (degraded but safe on a single user's account). +class DivelogsSyncPlanner { + const DivelogsSyncPlanner({this.matcher = const DiveMatcher()}); + + final DiveMatcher matcher; + + static const Duration _timeGate = Duration(minutes: 15); + + DivelogsSyncPlan plan({ + required List remote, + required List local, + }) { + final sortedRemote = [...remote] + ..sort((a, b) => a.dateTime.compareTo(b.dateTime)); + final unmatchedLocal = [...local]; + final pull = []; + var matched = 0; + + for (final entry in sortedRemote) { + DiveSummary? best; + var bestKey = double.negativeInfinity; + for (final summary in unmatchedLocal) { + final key = _matchKey(entry, summary); + if (key != null && key > bestKey) { + best = summary; + bestKey = key; + } + } + if (best != null) { + unmatchedLocal.remove(best); + matched++; + } else { + pull.add(entry); + } + } + + return DivelogsSyncPlan( + pullCandidates: pull, + pushCandidates: unmatchedLocal, + matchedCount: matched, + ); + } + + /// Returns a comparable match quality (higher is better), or null when + /// the pair does not match. + double? _matchKey(DivelogsDivelistEntry entry, DiveSummary summary) { + final localTime = summary.entryTime ?? summary.dateTime; + final timeDiff = entry.dateTime.difference(localTime).abs(); + if (timeDiff > _timeGate) return null; + + final localDuration = summary.runtime ?? summary.bottomTime; + final hasFullData = + entry.durationSeconds != null && + entry.maxDepth != null && + localDuration != null && + summary.maxDepth != null; + if (!hasFullData) { + // Degraded: time-gate only. Rank by time proximity below any real + // score so scored matches win when available. + return -timeDiff.inSeconds.toDouble() / _timeGate.inSeconds; + } + + final score = matcher.calculateMatchScore( + wearableStartTime: entry.dateTime, + wearableMaxDepth: entry.maxDepth!, + wearableDurationSeconds: entry.durationSeconds!, + existingStartTime: localTime, + existingMaxDepth: summary.maxDepth!, + existingDurationSeconds: localDuration.inSeconds, + ); + // Probable (>= 0.7), not merely possible (>= 0.5): a perfect time match + // alone scores exactly 0.5, and treating that as "already synced" would + // silently hide a dive whose depth/duration clearly disagree. + return matcher.isProbableDuplicate(score) ? score : null; + } +} diff --git a/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart b/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart new file mode 100644 index 0000000000..96e4c6c92a --- /dev/null +++ b/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_sync_planner.dart'; + +void main() { + const planner = DivelogsSyncPlanner(); + + DivelogsDivelistEntry remote( + String id, + DateTime at, { + int? duration = 2808, + double? depth = 12, + }) => DivelogsDivelistEntry( + id: id, + dateTime: at, + durationSeconds: duration, + maxDepth: depth, + ); + + DiveSummary local( + String id, + DateTime at, { + Duration? runtime = const Duration(seconds: 2808), + double? depth = 12, + }) => DiveSummary( + id: id, + dateTime: at, + entryTime: at, + runtime: runtime, + maxDepth: depth, + isFavorite: false, + diveTypeIds: const [], + tags: const [], + sortTimestamp: at.millisecondsSinceEpoch, + ); + + final t0 = DateTime.utc(2022, 9, 3, 14, 42); + + test('matched pairs are neither pulled nor pushed', () { + final plan = planner.plan( + remote: [remote('r1', t0)], + local: [local('l1', t0)], + ); + expect(plan.pullCandidates, isEmpty); + expect(plan.pushCandidates, isEmpty); + expect(plan.matchedCount, 1); + }); + + test('remote-only dives are pull candidates, local-only are push', () { + final plan = planner.plan( + remote: [remote('r1', t0), remote('r2', t0.add(const Duration(days: 1)))], + local: [local('l1', t0), local('l2', t0.add(const Duration(days: 2)))], + ); + expect(plan.pullCandidates.map((e) => e.id), ['r2']); + expect(plan.pushCandidates.map((s) => s.id), ['l2']); + expect(plan.matchedCount, 1); + }); + + test('time gate: 20 minutes apart is not a match', () { + final plan = planner.plan( + remote: [remote('r1', t0)], + local: [local('l1', t0.add(const Duration(minutes: 20)))], + ); + expect(plan.pullCandidates, hasLength(1)); + expect(plan.pushCandidates, hasLength(1)); + }); + + test('same time but wildly different depth/duration is not a match', () { + final plan = planner.plan( + remote: [remote('r1', t0, duration: 2808, depth: 40)], + local: [local('l1', t0, runtime: const Duration(minutes: 5), depth: 3)], + ); + expect(plan.pullCandidates, hasLength(1)); + expect(plan.pushCandidates, hasLength(1)); + }); + + test('degraded match: divelist without depth/duration matches on time', () { + final plan = planner.plan( + remote: [remote('r1', t0, duration: null, depth: null)], + local: [local('l1', t0.add(const Duration(minutes: 5)))], + ); + expect(plan.pullCandidates, isEmpty); + expect(plan.pushCandidates, isEmpty); + expect(plan.matchedCount, 1); + }); + + test('one-to-one: a single local dive cannot match two remote entries', () { + final plan = planner.plan( + remote: [ + remote('r1', t0), + remote('r2', t0.add(const Duration(minutes: 3))), + ], + local: [local('l1', t0)], + ); + expect(plan.matchedCount, 1); + expect(plan.pullCandidates, hasLength(1)); + expect(plan.pushCandidates, isEmpty); + }); +} From b4be95e7eb4119d0a40b0d8d22f4e6d427827521 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:03:48 -0400 Subject: [PATCH 19/35] feat: add chunked create-only divelogs.de push service --- .../services/divelogs_push_service.dart | 74 ++++++++++++++++ .../services/divelogs_push_service_test.dart | 87 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 lib/features/divelogs_sync/domain/services/divelogs_push_service.dart create mode 100644 test/features/divelogs_sync/domain/services/divelogs_push_service_test.dart diff --git a/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart b/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart new file mode 100644 index 0000000000..f55825d401 --- /dev/null +++ b/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart @@ -0,0 +1,74 @@ +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart'; + +class DivelogsPushResult { + final int pushed; + final int skippedUnmappable; + final String? error; + + const DivelogsPushResult({ + required this.pushed, + required this.skippedUnmappable, + this.error, + }); + + bool get failed => error != null; +} + +/// Create-only bulk push. A failure stops the run and reports partial +/// progress; no rollback is needed because the next compare simply matches +/// whatever was already created (stateless model, spec: push path). +class DivelogsPushService { + DivelogsPushService({ + required DivelogsApiClient api, + this.mapper = const DivelogsExportMapper(), + this.chunkSize = 50, + Future Function(Duration)? delay, + }) : _api = api, + _delay = delay ?? Future.delayed; + + final DivelogsApiClient _api; + final DivelogsExportMapper mapper; + final int chunkSize; + final Future Function(Duration) _delay; + + static const Duration _interChunkDelay = Duration(milliseconds: 200); + + Future push( + List dives, { + void Function(int done, int total)? onProgress, + }) async { + final mapped = >[]; + var skipped = 0; + for (final dive in dives) { + final json = mapper.mapDive(dive); + if (json == null) { + skipped++; + } else { + mapped.add(json); + } + } + + var pushed = 0; + for (var start = 0; start < mapped.length; start += chunkSize) { + if (start > 0) await _delay(_interChunkDelay); + final chunk = mapped.sublist( + start, + start + chunkSize > mapped.length ? mapped.length : start + chunkSize, + ); + try { + await _api.postDives(chunk); + } on DivelogsApiException catch (e) { + return DivelogsPushResult( + pushed: pushed, + skippedUnmappable: skipped, + error: e.message, + ); + } + pushed += chunk.length; + onProgress?.call(pushed, mapped.length); + } + return DivelogsPushResult(pushed: pushed, skippedUnmappable: skipped); + } +} diff --git a/test/features/divelogs_sync/domain/services/divelogs_push_service_test.dart b/test/features/divelogs_sync/domain/services/divelogs_push_service_test.dart new file mode 100644 index 0000000000..4a60caefec --- /dev/null +++ b/test/features/divelogs_sync/domain/services/divelogs_push_service_test.dart @@ -0,0 +1,87 @@ +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/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_push_service.dart'; + +void main() { + Dive dive(int n, {Duration? runtime = const Duration(minutes: 45)}) => Dive( + id: 'd$n', + dateTime: DateTime.utc(2022, 9, n + 1, 10), + entryTime: DateTime.utc(2022, 9, n + 1, 10), + runtime: runtime, + maxDepth: 10.0 + n, + ); + + DivelogsApiClient api(Future Function(http.Request) handler) => + DivelogsApiClient( + getBearerToken: () async => 't', + onTokenRejected: () {}, + httpClient: MockClient(handler), + ); + + DivelogsPushService service(DivelogsApiClient client, {int chunkSize = 2}) => + DivelogsPushService( + api: client, + chunkSize: chunkSize, + delay: (_) async {}, + ); + + test('chunks dives and reports progress', () async { + final batches = []; + final progress = <(int, int)>[]; + final result = + await service( + api((req) async { + batches.add((jsonDecode(req.body) as List).length); + return http.Response('{}', 200); + }), + ).push([ + dive(1), + dive(2), + dive(3), + ], onProgress: (done, total) => progress.add((done, total))); + expect(batches, [2, 1]); + expect(progress, [(2, 3), (3, 3)]); + expect(result.pushed, 3); + expect(result.skippedUnmappable, 0); + expect(result.failed, isFalse); + }); + + test('unmappable dives are counted and not sent', () async { + var sent = 0; + final result = await service( + api((req) async { + sent += (jsonDecode(req.body) as List).length; + return http.Response('{}', 200); + }), + ).push([dive(1), dive(2, runtime: null)]); + expect(sent, 1); + expect(result.pushed, 1); + expect(result.skippedUnmappable, 1); + }); + + test('a failed chunk stops the push and reports partial progress', () async { + var call = 0; + final result = await service( + api((req) async { + call++; + return call == 1 ? http.Response('{}', 200) : http.Response('', 500); + }), + ).push([dive(1), dive(2), dive(3)]); + expect(result.pushed, 2); + expect(result.failed, isTrue); + expect(result.error, contains('500')); + }); + + test('empty mapped list makes no network calls', () async { + final result = await service( + api((req) async => fail('no call expected')), + ).push([dive(1, runtime: null)]); + expect(result.pushed, 0); + expect(result.skippedUnmappable, 1); + }); +} From b5bda1501893a9ae96f3b8b7325a8b4ab0224ccc Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:10:55 -0400 Subject: [PATCH 20/35] feat: add divelogs.de sync page with compare and chunked push --- lib/core/router/app_router.dart | 6 + .../pages/divelogs_sync_page.dart | 376 ++++++++++++++++++ .../pages/connected_accounts_page.dart | 6 + lib/l10n/arb/app_ar.arb | 15 + lib/l10n/arb/app_de.arb | 15 + lib/l10n/arb/app_en.arb | 21 + lib/l10n/arb/app_es.arb | 15 + lib/l10n/arb/app_fr.arb | 15 + lib/l10n/arb/app_he.arb | 15 + lib/l10n/arb/app_hu.arb | 15 + lib/l10n/arb/app_it.arb | 15 + lib/l10n/arb/app_localizations.dart | 90 +++++ lib/l10n/arb/app_localizations_ar.dart | 58 +++ lib/l10n/arb/app_localizations_de.dart | 60 +++ lib/l10n/arb/app_localizations_en.dart | 58 +++ lib/l10n/arb/app_localizations_es.dart | 59 +++ lib/l10n/arb/app_localizations_fr.dart | 59 +++ lib/l10n/arb/app_localizations_he.dart | 58 +++ lib/l10n/arb/app_localizations_hu.dart | 59 +++ lib/l10n/arb/app_localizations_it.dart | 59 +++ lib/l10n/arb/app_localizations_nl.dart | 59 +++ lib/l10n/arb/app_localizations_pt.dart | 59 +++ lib/l10n/arb/app_localizations_zh.dart | 57 +++ lib/l10n/arb/app_nl.arb | 15 + lib/l10n/arb/app_pt.arb | 15 + lib/l10n/arb/app_zh.arb | 15 + .../pages/divelogs_sync_page_test.dart | 217 ++++++++++ 27 files changed, 1511 insertions(+) create mode 100644 lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart create mode 100644 test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 3621dfab30..c4f094bd26 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -133,6 +133,7 @@ import 'package:submersion/features/dashboard/presentation/pages/dashboard_page. import 'package:submersion/features/planner/presentation/pages/plan_canvas_page.dart'; import 'package:submersion/features/planner/presentation/pages/plan_compare_page.dart'; import 'package:submersion/features/surface_interval_tool/presentation/pages/surface_interval_tool_page.dart'; +import 'package:submersion/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart'; import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -975,6 +976,11 @@ final appRouterProvider = Provider((ref) { name: 'connectedAccounts', builder: (context, state) => const ConnectedAccountsPage(), ), + GoRoute( + path: 'divelogs-sync', + name: 'divelogsSync', + builder: (context, state) => const DivelogsSyncPage(), + ), GoRoute( path: 'fix-dive-times', name: 'fixDiveTimes', diff --git a/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart new file mode 100644 index 0000000000..fd20338b2c --- /dev/null +++ b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/core/providers/account_providers.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; +import 'package:submersion/core/services/accounts/account_provider_adapter.dart'; +import 'package:submersion/core/services/accounts/adapters/divelogs_account_adapter.dart'; +import 'package:submersion/core/services/accounts/connected_account.dart'; +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/core/services/divelogs/divelogs_auth_manager.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_push_service.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_sync_planner.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +enum _PagePhase { + loading, + notConnected, + wrongDiver, + idle, + comparing, + plan, + pushing, + error, +} + +/// Compare-and-push page for a connected divelogs.de account (spec: sync +/// page). Pull review is delegated to the Phase 1 import wizard; push has +/// its own per-dive selection here. +class DivelogsSyncPage extends ConsumerStatefulWidget { + const DivelogsSyncPage({super.key}); + + @override + ConsumerState createState() => _DivelogsSyncPageState(); +} + +class _DivelogsSyncPageState extends ConsumerState { + _PagePhase _phase = _PagePhase.loading; + ConnectedAccount? _account; + DivelogsSyncPlan? _plan; + Set _selectedPushIds = {}; + String? _errorMessage; + int _pushDone = 0; + int _pushTotal = 0; + DivelogsPushResult? _lastPushResult; + + @override + void initState() { + super.initState(); + Future.microtask(_init); + } + + Future _init() async { + final repo = ref.read(connectedAccountsRepositoryProvider); + final account = await repo.getByKind(AccountKind.divelogs); + if (!mounted) return; + if (account == null) { + setState(() => _phase = _PagePhase.notConnected); + return; + } + final status = await _adapter.status(account); + if (!mounted) return; + if (status != AccountStatus.signedIn) { + setState(() => _phase = _PagePhase.notConnected); + return; + } + final currentDiver = await ref.read(currentDiverProvider.future); + if (!mounted) return; + if (account.diverId != null && + currentDiver != null && + account.diverId != currentDiver.id) { + setState(() => _phase = _PagePhase.wrongDiver); + return; + } + setState(() { + _account = account; + _phase = _PagePhase.idle; + }); + } + + DivelogsAccountAdapter get _adapter => + ref.read(accountProviderRegistryProvider).adapterFor(AccountKind.divelogs) + as DivelogsAccountAdapter; + + DivelogsApiClient _api(ConnectedAccount account) { + final manager = _adapter.authManagerFor(account); + return DivelogsApiClient( + getBearerToken: manager.getToken, + onTokenRejected: manager.invalidateToken, + httpClient: ref.read(divelogsHttpClientProvider), + ); + } + + Future _compare() async { + final account = _account; + if (account == null) return; + setState(() { + _phase = _PagePhase.comparing; + _errorMessage = null; + }); + try { + final remote = await _api(account).getDivelist(); + final currentDiver = await ref.read(currentDiverProvider.future); + final diverId = account.diverId ?? currentDiver?.id; + final local = await ref + .read(diveRepositoryProvider) + .getDiveSummaries(diverId: diverId, limit: 1000000); + if (!mounted) return; + final plan = const DivelogsSyncPlanner().plan( + remote: remote.entries, + local: local, + ); + setState(() { + _plan = plan; + _selectedPushIds = plan.pushCandidates.map((s) => s.id).toSet(); + _phase = _PagePhase.plan; + }); + } on DivelogsApiException catch (e) { + if (!mounted) return; + setState(() { + _phase = _PagePhase.error; + _errorMessage = e.message; + }); + } on DivelogsAuthException catch (e) { + if (!mounted) return; + setState(() { + _phase = _PagePhase.error; + _errorMessage = e.message; + }); + } + } + + Future _push() async { + final account = _account; + final plan = _plan; + if (account == null || plan == null || _selectedPushIds.isEmpty) return; + setState(() { + _phase = _PagePhase.pushing; + _pushDone = 0; + _pushTotal = _selectedPushIds.length; + }); + final dives = await ref + .read(diveRepositoryProvider) + .getDivesByIds(_selectedPushIds.toList()); + if (!mounted) return; + final result = await DivelogsPushService(api: _api(account)).push( + dives, + onProgress: (done, total) { + if (!mounted) return; + setState(() { + _pushDone = done; + _pushTotal = total; + }); + }, + ); + if (!mounted) return; + _lastPushResult = result; + // Stateless model: re-compare so pushed dives show up as matched. + await _compare(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar(title: Text(l10n.divelogsSync_title)), + body: switch (_phase) { + _PagePhase.loading => const Center(child: CircularProgressIndicator()), + _PagePhase.notConnected => _buildNotConnected(context), + _PagePhase.wrongDiver => _buildMessage( + context, + l10n.divelogs_fetch_wrongDiver, + ), + _PagePhase.idle => Center( + child: FilledButton( + onPressed: _compare, + child: Text(l10n.divelogsSync_compare), + ), + ), + _PagePhase.comparing => _buildProgress(l10n.divelogsSync_comparing), + _PagePhase.plan => _buildPlan(context), + _PagePhase.pushing => _buildPushing(context), + _PagePhase.error => _buildError(context), + }, + ); + } + + Widget _buildNotConnected(BuildContext context) { + final l10n = context.l10n; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.divelogsSync_notConnected, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.push('/transfer/divelogs-import'), + child: Text(l10n.divelogsSync_openImport), + ), + ], + ), + ), + ); + } + + Widget _buildProgress(String message) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(message), + ], + ), + ); + } + + Widget _buildMessage(BuildContext context, String message) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(message, textAlign: TextAlign.center), + ), + ); + } + + Widget _buildPushing(BuildContext context) { + final l10n = context.l10n; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + value: _pushTotal == 0 ? null : _pushDone / _pushTotal, + ), + const SizedBox(height: 16), + Text(l10n.divelogsSync_pushing), + ], + ), + ), + ); + } + + Widget _buildError(BuildContext context) { + final l10n = context.l10n; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _errorMessage ?? l10n.divelogs_fetch_error, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _compare, + child: Text(l10n.divelogs_fetch_retry), + ), + ], + ), + ), + ); + } + + Widget _buildPlan(BuildContext context) { + final l10n = context.l10n; + final plan = _plan!; + final push = _lastPushResult; + final dateFormat = MaterialLocalizations.of(context); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (push != null) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + push.failed + ? l10n.divelogsSync_pushFailedPartial( + push.pushed, + push.error!, + ) + : l10n.divelogsSync_pushDone(push.pushed), + ), + if (push.skippedUnmappable > 0) + Text(l10n.divelogsSync_pushSkipped(push.skippedUnmappable)), + ], + ), + ), + ), + const SizedBox(height: 12), + ], + Row( + children: [ + Expanded(child: Text(l10n.divelogsSync_matched(plan.matchedCount))), + TextButton( + onPressed: _compare, + child: Text(l10n.divelogsSync_compare), + ), + ], + ), + const Divider(), + if (plan.pullCandidates.isEmpty && plan.pushCandidates.isEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Text( + l10n.divelogsSync_nothingToSync, + textAlign: TextAlign.center, + ), + ), + if (plan.pullCandidates.isNotEmpty) ...[ + Text( + l10n.divelogsSync_pullHeader(plan.pullCandidates.length), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () => context.push('/transfer/divelogs-import'), + child: Text(l10n.divelogsSync_pullReview), + ), + const SizedBox(height: 16), + ], + if (plan.pushCandidates.isNotEmpty) ...[ + Text( + l10n.divelogsSync_pushHeader(plan.pushCandidates.length), + style: Theme.of(context).textTheme.titleMedium, + ), + for (final summary in plan.pushCandidates) + CheckboxListTile( + value: _selectedPushIds.contains(summary.id), + onChanged: (checked) => setState(() { + if (checked == true) { + _selectedPushIds = {..._selectedPushIds, summary.id}; + } else { + _selectedPushIds = {..._selectedPushIds}..remove(summary.id); + } + }), + title: Text(_summaryTitle(summary)), + subtitle: Text( + dateFormat.formatShortDate( + summary.entryTime ?? summary.dateTime, + ), + ), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: _selectedPushIds.isEmpty ? null : _push, + child: Text(l10n.divelogsSync_pushSelected), + ), + ], + ], + ); + } + + String _summaryTitle(DiveSummary summary) { + final name = summary.name; + if (name != null && name.isNotEmpty) return name; + final number = summary.diveNumber; + if (number != null) return '#$number'; + return summary.siteName ?? summary.id; + } +} diff --git a/lib/features/settings/presentation/pages/connected_accounts_page.dart b/lib/features/settings/presentation/pages/connected_accounts_page.dart index f3630e3def..dac5ee91bd 100644 --- a/lib/features/settings/presentation/pages/connected_accounts_page.dart +++ b/lib/features/settings/presentation/pages/connected_accounts_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:submersion/core/providers/account_providers.dart'; import 'package:submersion/core/services/accounts/account_kind.dart'; @@ -102,6 +103,11 @@ class _AccountTile extends ConsumerWidget { }; return ListTile( + // divelogs accounts open their sync page; other kinds are managed + // from their own settings surfaces. + onTap: account.kind == AccountKind.divelogs + ? () => context.push('/settings/divelogs-sync') + : null, leading: Icon(_icon), title: Text(account.label), subtitle: Text( diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index e87ef3cf53..51fff48bca 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "مزامنة divelogs.de", + "divelogsSync_notConnected": "لا يوجد حساب divelogs.de متصل بعد. ابدأ استيرادا لتسجيل الدخول.", + "divelogsSync_openImport": "فتح استيراد divelogs.de", + "divelogsSync_compare": "مقارنة", + "divelogsSync_comparing": "جارٍ المقارنة مع divelogs.de...", + "divelogsSync_matched": "{count} غطسات متزامنة بالفعل", + "divelogsSync_pullHeader": "سحب: {count} جديدة على divelogs.de", + "divelogsSync_pullReview": "راجع واسحب في معالج الاستيراد", + "divelogsSync_pushHeader": "دفع: {count} غطسات غير موجودة على divelogs.de", + "divelogsSync_pushSelected": "دفع المحدد", + "divelogsSync_pushing": "جارٍ دفع الغطسات إلى divelogs.de...", + "divelogsSync_pushDone": "تم دفع {count} غطسات إلى divelogs.de.", + "divelogsSync_pushSkipped": "تعذر تحويل {count} غطسات وتم تخطيها.", + "divelogsSync_pushFailedPartial": "توقف الدفع بعد {count} غطسات: {error}", + "divelogsSync_nothingToSync": "كل شيء متزامن.", "divelogs_signIn_title": "تسجيل الدخول إلى divelogs.de", "divelogs_signIn_username": "اسم المستخدم", "divelogs_signIn_password": "كلمة المرور", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index e0b5c85e63..7dc1a28668 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "divelogs.de-Synchronisierung", + "divelogsSync_notConnected": "Es ist noch kein divelogs.de-Konto verbunden. Starten Sie einen Import, um sich anzumelden.", + "divelogsSync_openImport": "divelogs.de-Import öffnen", + "divelogsSync_compare": "Vergleichen", + "divelogsSync_comparing": "Vergleich mit divelogs.de...", + "divelogsSync_matched": "{count} Tauchgänge bereits synchron", + "divelogsSync_pullHeader": "Laden: {count} neue auf divelogs.de", + "divelogsSync_pullReview": "Im Import-Assistenten prüfen und laden", + "divelogsSync_pushHeader": "Senden: {count} Tauchgänge nicht auf divelogs.de", + "divelogsSync_pushSelected": "Ausgewählte senden", + "divelogsSync_pushing": "Tauchgänge werden an divelogs.de gesendet...", + "divelogsSync_pushDone": "{count} Tauchgänge an divelogs.de gesendet.", + "divelogsSync_pushSkipped": "{count} Tauchgänge konnten nicht konvertiert werden und wurden übersprungen.", + "divelogsSync_pushFailedPartial": "Senden nach {count} Tauchgängen gestoppt: {error}", + "divelogsSync_nothingToSync": "Alles ist synchron.", "divelogs_signIn_title": "Bei divelogs.de anmelden", "divelogs_signIn_username": "Benutzername", "divelogs_signIn_password": "Passwort", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2e0d7c1cf9..22d2e2d176 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,25 @@ { + "divelogsSync_title": "divelogs.de Sync", + "divelogsSync_notConnected": "No divelogs.de account is connected yet. Start an import to sign in.", + "divelogsSync_openImport": "Open divelogs.de import", + "divelogsSync_compare": "Compare", + "divelogsSync_comparing": "Comparing with divelogs.de...", + "divelogsSync_matched": "{count} dives already in sync", + "@divelogsSync_matched": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_pullHeader": "Pull: {count} new on divelogs.de", + "@divelogsSync_pullHeader": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_pullReview": "Review and pull in the import wizard", + "divelogsSync_pushHeader": "Push: {count} dives not on divelogs.de", + "@divelogsSync_pushHeader": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_pushSelected": "Push selected", + "divelogsSync_pushing": "Pushing dives to divelogs.de...", + "divelogsSync_pushDone": "Pushed {count} dives to divelogs.de.", + "@divelogsSync_pushDone": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_pushSkipped": "{count} dives could not be converted and were skipped.", + "@divelogsSync_pushSkipped": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_pushFailedPartial": "Push stopped after {count} dives: {error}", + "@divelogsSync_pushFailedPartial": {"placeholders": {"count": {"type": "int"}, "error": {"type": "String"}}}, + "divelogsSync_nothingToSync": "Everything is in sync.", "divelogs_signIn_title": "Sign in to divelogs.de", "divelogs_signIn_username": "Username", "divelogs_signIn_password": "Password", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 81d2770e1d..ac35e7d7a4 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "Sincronización con divelogs.de", + "divelogsSync_notConnected": "Aún no hay una cuenta de divelogs.de conectada. Inicia una importación para iniciar sesión.", + "divelogsSync_openImport": "Abrir importación de divelogs.de", + "divelogsSync_compare": "Comparar", + "divelogsSync_comparing": "Comparando con divelogs.de...", + "divelogsSync_matched": "{count} inmersiones ya sincronizadas", + "divelogsSync_pullHeader": "Descargar: {count} nuevas en divelogs.de", + "divelogsSync_pullReview": "Revisar y descargar en el asistente de importación", + "divelogsSync_pushHeader": "Subir: {count} inmersiones que no están en divelogs.de", + "divelogsSync_pushSelected": "Subir seleccionadas", + "divelogsSync_pushing": "Subiendo inmersiones a divelogs.de...", + "divelogsSync_pushDone": "Se subieron {count} inmersiones a divelogs.de.", + "divelogsSync_pushSkipped": "{count} inmersiones no se pudieron convertir y se omitieron.", + "divelogsSync_pushFailedPartial": "La subida se detuvo tras {count} inmersiones: {error}", + "divelogsSync_nothingToSync": "Todo está sincronizado.", "divelogs_signIn_title": "Iniciar sesión en divelogs.de", "divelogs_signIn_username": "Nombre de usuario", "divelogs_signIn_password": "Contraseña", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index ca60d5a177..ac449a96d1 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "Synchronisation divelogs.de", + "divelogsSync_notConnected": "Aucun compte divelogs.de n'est encore connecté. Lancez une importation pour vous connecter.", + "divelogsSync_openImport": "Ouvrir l'importation divelogs.de", + "divelogsSync_compare": "Comparer", + "divelogsSync_comparing": "Comparaison avec divelogs.de...", + "divelogsSync_matched": "{count} plongées déjà synchronisées", + "divelogsSync_pullHeader": "Récupérer : {count} nouvelles sur divelogs.de", + "divelogsSync_pullReview": "Vérifier et récupérer dans l'assistant d'importation", + "divelogsSync_pushHeader": "Envoyer : {count} plongées absentes de divelogs.de", + "divelogsSync_pushSelected": "Envoyer la sélection", + "divelogsSync_pushing": "Envoi des plongées vers divelogs.de...", + "divelogsSync_pushDone": "{count} plongées envoyées vers divelogs.de.", + "divelogsSync_pushSkipped": "{count} plongées n'ont pas pu être converties et ont été ignorées.", + "divelogsSync_pushFailedPartial": "Envoi arrêté après {count} plongées : {error}", + "divelogsSync_nothingToSync": "Tout est synchronisé.", "divelogs_signIn_title": "Se connecter à divelogs.de", "divelogs_signIn_username": "Nom d'utilisateur", "divelogs_signIn_password": "Mot de passe", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index df399a6b61..55a4409731 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "סנכרון divelogs.de", + "divelogsSync_notConnected": "עדיין לא מחובר חשבון divelogs.de. התחל ייבוא כדי להתחבר.", + "divelogsSync_openImport": "פתח ייבוא divelogs.de", + "divelogsSync_compare": "השווה", + "divelogsSync_comparing": "משווה מול divelogs.de...", + "divelogsSync_matched": "{count} צלילות כבר מסונכרנות", + "divelogsSync_pullHeader": "משיכה: {count} חדשות ב-divelogs.de", + "divelogsSync_pullReview": "בדוק ומשוך באשף הייבוא", + "divelogsSync_pushHeader": "דחיפה: {count} צלילות שאינן ב-divelogs.de", + "divelogsSync_pushSelected": "דחוף נבחרות", + "divelogsSync_pushing": "דוחף צלילות ל-divelogs.de...", + "divelogsSync_pushDone": "{count} צלילות נדחפו ל-divelogs.de.", + "divelogsSync_pushSkipped": "{count} צלילות לא ניתנות להמרה ודולגו.", + "divelogsSync_pushFailedPartial": "הדחיפה נעצרה אחרי {count} צלילות: {error}", + "divelogsSync_nothingToSync": "הכול מסונכרן.", "divelogs_signIn_title": "התחברות ל-divelogs.de", "divelogs_signIn_username": "שם משתמש", "divelogs_signIn_password": "סיסמה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 390cf83cd0..40a863e22d 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "divelogs.de szinkronizálás", + "divelogsSync_notConnected": "Még nincs csatlakoztatott divelogs.de-fiók. Indíts egy importot a bejelentkezéshez.", + "divelogsSync_openImport": "divelogs.de-import megnyitása", + "divelogsSync_compare": "Összehasonlítás", + "divelogsSync_comparing": "Összehasonlítás a divelogs.de-vel...", + "divelogsSync_matched": "{count} merülés már szinkronban", + "divelogsSync_pullHeader": "Letöltés: {count} új a divelogs.de-n", + "divelogsSync_pullReview": "Ellenőrzés és letöltés az importvarázslóban", + "divelogsSync_pushHeader": "Feltöltés: {count} merülés nincs a divelogs.de-n", + "divelogsSync_pushSelected": "Kijelöltek feltöltése", + "divelogsSync_pushing": "Merülések feltöltése a divelogs.de-re...", + "divelogsSync_pushDone": "{count} merülés feltöltve a divelogs.de-re.", + "divelogsSync_pushSkipped": "{count} merülést nem lehetett konvertálni, ezért kimaradt.", + "divelogsSync_pushFailedPartial": "A feltöltés {count} merülés után leállt: {error}", + "divelogsSync_nothingToSync": "Minden szinkronban van.", "divelogs_signIn_title": "Bejelentkezés a divelogs.de-re", "divelogs_signIn_username": "Felhasználónév", "divelogs_signIn_password": "Jelszó", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index cefb585f60..b92a728146 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "Sincronizzazione divelogs.de", + "divelogsSync_notConnected": "Nessun account divelogs.de collegato. Avvia un'importazione per accedere.", + "divelogsSync_openImport": "Apri importazione divelogs.de", + "divelogsSync_compare": "Confronta", + "divelogsSync_comparing": "Confronto con divelogs.de...", + "divelogsSync_matched": "{count} immersioni già sincronizzate", + "divelogsSync_pullHeader": "Scarica: {count} nuove su divelogs.de", + "divelogsSync_pullReview": "Rivedi e scarica nella procedura di importazione", + "divelogsSync_pushHeader": "Carica: {count} immersioni non presenti su divelogs.de", + "divelogsSync_pushSelected": "Carica selezionate", + "divelogsSync_pushing": "Caricamento immersioni su divelogs.de...", + "divelogsSync_pushDone": "{count} immersioni caricate su divelogs.de.", + "divelogsSync_pushSkipped": "{count} immersioni non convertibili sono state ignorate.", + "divelogsSync_pushFailedPartial": "Caricamento interrotto dopo {count} immersioni: {error}", + "divelogsSync_nothingToSync": "Tutto è sincronizzato.", "divelogs_signIn_title": "Accedi a divelogs.de", "divelogs_signIn_username": "Nome utente", "divelogs_signIn_password": "Password", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 0bd6f21412..e19718c24c 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -116,6 +116,96 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @divelogsSync_title. + /// + /// In en, this message translates to: + /// **'divelogs.de Sync'** + String get divelogsSync_title; + + /// No description provided for @divelogsSync_notConnected. + /// + /// In en, this message translates to: + /// **'No divelogs.de account is connected yet. Start an import to sign in.'** + String get divelogsSync_notConnected; + + /// No description provided for @divelogsSync_openImport. + /// + /// In en, this message translates to: + /// **'Open divelogs.de import'** + String get divelogsSync_openImport; + + /// No description provided for @divelogsSync_compare. + /// + /// In en, this message translates to: + /// **'Compare'** + String get divelogsSync_compare; + + /// No description provided for @divelogsSync_comparing. + /// + /// In en, this message translates to: + /// **'Comparing with divelogs.de...'** + String get divelogsSync_comparing; + + /// No description provided for @divelogsSync_matched. + /// + /// In en, this message translates to: + /// **'{count} dives already in sync'** + String divelogsSync_matched(int count); + + /// No description provided for @divelogsSync_pullHeader. + /// + /// In en, this message translates to: + /// **'Pull: {count} new on divelogs.de'** + String divelogsSync_pullHeader(int count); + + /// No description provided for @divelogsSync_pullReview. + /// + /// In en, this message translates to: + /// **'Review and pull in the import wizard'** + String get divelogsSync_pullReview; + + /// No description provided for @divelogsSync_pushHeader. + /// + /// In en, this message translates to: + /// **'Push: {count} dives not on divelogs.de'** + String divelogsSync_pushHeader(int count); + + /// No description provided for @divelogsSync_pushSelected. + /// + /// In en, this message translates to: + /// **'Push selected'** + String get divelogsSync_pushSelected; + + /// No description provided for @divelogsSync_pushing. + /// + /// In en, this message translates to: + /// **'Pushing dives to divelogs.de...'** + String get divelogsSync_pushing; + + /// No description provided for @divelogsSync_pushDone. + /// + /// In en, this message translates to: + /// **'Pushed {count} dives to divelogs.de.'** + String divelogsSync_pushDone(int count); + + /// No description provided for @divelogsSync_pushSkipped. + /// + /// In en, this message translates to: + /// **'{count} dives could not be converted and were skipped.'** + String divelogsSync_pushSkipped(int count); + + /// No description provided for @divelogsSync_pushFailedPartial. + /// + /// In en, this message translates to: + /// **'Push stopped after {count} dives: {error}'** + String divelogsSync_pushFailedPartial(int count, String error); + + /// No description provided for @divelogsSync_nothingToSync. + /// + /// In en, this message translates to: + /// **'Everything is in sync.'** + String get divelogsSync_nothingToSync; + /// No description provided for @divelogs_signIn_title. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 117eb58479..fbec40ef2e 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8,6 +8,64 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get divelogsSync_title => 'مزامنة divelogs.de'; + + @override + String get divelogsSync_notConnected => + 'لا يوجد حساب divelogs.de متصل بعد. ابدأ استيرادا لتسجيل الدخول.'; + + @override + String get divelogsSync_openImport => 'فتح استيراد divelogs.de'; + + @override + String get divelogsSync_compare => 'مقارنة'; + + @override + String get divelogsSync_comparing => 'جارٍ المقارنة مع divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count غطسات متزامنة بالفعل'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'سحب: $count جديدة على divelogs.de'; + } + + @override + String get divelogsSync_pullReview => 'راجع واسحب في معالج الاستيراد'; + + @override + String divelogsSync_pushHeader(int count) { + return 'دفع: $count غطسات غير موجودة على divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'دفع المحدد'; + + @override + String get divelogsSync_pushing => 'جارٍ دفع الغطسات إلى divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return 'تم دفع $count غطسات إلى divelogs.de.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return 'تعذر تحويل $count غطسات وتم تخطيها.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'توقف الدفع بعد $count غطسات: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'كل شيء متزامن.'; + @override String get divelogs_signIn_title => 'تسجيل الدخول إلى divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 241655f994..6a8ef0539b 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8,6 +8,66 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get divelogsSync_title => 'divelogs.de-Synchronisierung'; + + @override + String get divelogsSync_notConnected => + 'Es ist noch kein divelogs.de-Konto verbunden. Starten Sie einen Import, um sich anzumelden.'; + + @override + String get divelogsSync_openImport => 'divelogs.de-Import öffnen'; + + @override + String get divelogsSync_compare => 'Vergleichen'; + + @override + String get divelogsSync_comparing => 'Vergleich mit divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count Tauchgänge bereits synchron'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Laden: $count neue auf divelogs.de'; + } + + @override + String get divelogsSync_pullReview => + 'Im Import-Assistenten prüfen und laden'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Senden: $count Tauchgänge nicht auf divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'Ausgewählte senden'; + + @override + String get divelogsSync_pushing => + 'Tauchgänge werden an divelogs.de gesendet...'; + + @override + String divelogsSync_pushDone(int count) { + return '$count Tauchgänge an divelogs.de gesendet.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count Tauchgänge konnten nicht konvertiert werden und wurden übersprungen.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'Senden nach $count Tauchgängen gestoppt: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Alles ist synchron.'; + @override String get divelogs_signIn_title => 'Bei divelogs.de anmelden'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index b82c6dba39..7fbc99df37 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8,6 +8,64 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get divelogsSync_title => 'divelogs.de Sync'; + + @override + String get divelogsSync_notConnected => + 'No divelogs.de account is connected yet. Start an import to sign in.'; + + @override + String get divelogsSync_openImport => 'Open divelogs.de import'; + + @override + String get divelogsSync_compare => 'Compare'; + + @override + String get divelogsSync_comparing => 'Comparing with divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count dives already in sync'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Pull: $count new on divelogs.de'; + } + + @override + String get divelogsSync_pullReview => 'Review and pull in the import wizard'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Push: $count dives not on divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'Push selected'; + + @override + String get divelogsSync_pushing => 'Pushing dives to divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return 'Pushed $count dives to divelogs.de.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count dives could not be converted and were skipped.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'Push stopped after $count dives: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Everything is in sync.'; + @override String get divelogs_signIn_title => 'Sign in to divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index d19a8fb537..f704153cee 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8,6 +8,65 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get divelogsSync_title => 'Sincronización con divelogs.de'; + + @override + String get divelogsSync_notConnected => + 'Aún no hay una cuenta de divelogs.de conectada. Inicia una importación para iniciar sesión.'; + + @override + String get divelogsSync_openImport => 'Abrir importación de divelogs.de'; + + @override + String get divelogsSync_compare => 'Comparar'; + + @override + String get divelogsSync_comparing => 'Comparando con divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count inmersiones ya sincronizadas'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Descargar: $count nuevas en divelogs.de'; + } + + @override + String get divelogsSync_pullReview => + 'Revisar y descargar en el asistente de importación'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Subir: $count inmersiones que no están en divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'Subir seleccionadas'; + + @override + String get divelogsSync_pushing => 'Subiendo inmersiones a divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return 'Se subieron $count inmersiones a divelogs.de.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count inmersiones no se pudieron convertir y se omitieron.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'La subida se detuvo tras $count inmersiones: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Todo está sincronizado.'; + @override String get divelogs_signIn_title => 'Iniciar sesión en divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 30bc69ecfd..7abe17fc1e 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8,6 +8,65 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get divelogsSync_title => 'Synchronisation divelogs.de'; + + @override + String get divelogsSync_notConnected => + 'Aucun compte divelogs.de n\'est encore connecté. Lancez une importation pour vous connecter.'; + + @override + String get divelogsSync_openImport => 'Ouvrir l\'importation divelogs.de'; + + @override + String get divelogsSync_compare => 'Comparer'; + + @override + String get divelogsSync_comparing => 'Comparaison avec divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count plongées déjà synchronisées'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Récupérer : $count nouvelles sur divelogs.de'; + } + + @override + String get divelogsSync_pullReview => + 'Vérifier et récupérer dans l\'assistant d\'importation'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Envoyer : $count plongées absentes de divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'Envoyer la sélection'; + + @override + String get divelogsSync_pushing => 'Envoi des plongées vers divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return '$count plongées envoyées vers divelogs.de.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count plongées n\'ont pas pu être converties et ont été ignorées.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'Envoi arrêté après $count plongées : $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Tout est synchronisé.'; + @override String get divelogs_signIn_title => 'Se connecter à divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 4bdc7df28f..ec42be3d48 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8,6 +8,64 @@ import 'app_localizations.dart'; class AppLocalizationsHe extends AppLocalizations { AppLocalizationsHe([String locale = 'he']) : super(locale); + @override + String get divelogsSync_title => 'סנכרון divelogs.de'; + + @override + String get divelogsSync_notConnected => + 'עדיין לא מחובר חשבון divelogs.de. התחל ייבוא כדי להתחבר.'; + + @override + String get divelogsSync_openImport => 'פתח ייבוא divelogs.de'; + + @override + String get divelogsSync_compare => 'השווה'; + + @override + String get divelogsSync_comparing => 'משווה מול divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count צלילות כבר מסונכרנות'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'משיכה: $count חדשות ב-divelogs.de'; + } + + @override + String get divelogsSync_pullReview => 'בדוק ומשוך באשף הייבוא'; + + @override + String divelogsSync_pushHeader(int count) { + return 'דחיפה: $count צלילות שאינן ב-divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'דחוף נבחרות'; + + @override + String get divelogsSync_pushing => 'דוחף צלילות ל-divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return '$count צלילות נדחפו ל-divelogs.de.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count צלילות לא ניתנות להמרה ודולגו.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'הדחיפה נעצרה אחרי $count צלילות: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'הכול מסונכרן.'; + @override String get divelogs_signIn_title => 'התחברות ל-divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 8f418f0ea2..f1a86c6bc5 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8,6 +8,65 @@ import 'app_localizations.dart'; class AppLocalizationsHu extends AppLocalizations { AppLocalizationsHu([String locale = 'hu']) : super(locale); + @override + String get divelogsSync_title => 'divelogs.de szinkronizálás'; + + @override + String get divelogsSync_notConnected => + 'Még nincs csatlakoztatott divelogs.de-fiók. Indíts egy importot a bejelentkezéshez.'; + + @override + String get divelogsSync_openImport => 'divelogs.de-import megnyitása'; + + @override + String get divelogsSync_compare => 'Összehasonlítás'; + + @override + String get divelogsSync_comparing => 'Összehasonlítás a divelogs.de-vel...'; + + @override + String divelogsSync_matched(int count) { + return '$count merülés már szinkronban'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Letöltés: $count új a divelogs.de-n'; + } + + @override + String get divelogsSync_pullReview => + 'Ellenőrzés és letöltés az importvarázslóban'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Feltöltés: $count merülés nincs a divelogs.de-n'; + } + + @override + String get divelogsSync_pushSelected => 'Kijelöltek feltöltése'; + + @override + String get divelogsSync_pushing => 'Merülések feltöltése a divelogs.de-re...'; + + @override + String divelogsSync_pushDone(int count) { + return '$count merülés feltöltve a divelogs.de-re.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count merülést nem lehetett konvertálni, ezért kimaradt.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'A feltöltés $count merülés után leállt: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Minden szinkronban van.'; + @override String get divelogs_signIn_title => 'Bejelentkezés a divelogs.de-re'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index b1709369df..62e8e25cfd 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8,6 +8,65 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get divelogsSync_title => 'Sincronizzazione divelogs.de'; + + @override + String get divelogsSync_notConnected => + 'Nessun account divelogs.de collegato. Avvia un\'importazione per accedere.'; + + @override + String get divelogsSync_openImport => 'Apri importazione divelogs.de'; + + @override + String get divelogsSync_compare => 'Confronta'; + + @override + String get divelogsSync_comparing => 'Confronto con divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count immersioni già sincronizzate'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Scarica: $count nuove su divelogs.de'; + } + + @override + String get divelogsSync_pullReview => + 'Rivedi e scarica nella procedura di importazione'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Carica: $count immersioni non presenti su divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'Carica selezionate'; + + @override + String get divelogsSync_pushing => 'Caricamento immersioni su divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return '$count immersioni caricate su divelogs.de.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count immersioni non convertibili sono state ignorate.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'Caricamento interrotto dopo $count immersioni: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Tutto è sincronizzato.'; + @override String get divelogs_signIn_title => 'Accedi a divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 69c2e70085..15fcc68d20 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8,6 +8,65 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String get divelogsSync_title => 'divelogs.de-synchronisatie'; + + @override + String get divelogsSync_notConnected => + 'Er is nog geen divelogs.de-account gekoppeld. Start een import om aan te melden.'; + + @override + String get divelogsSync_openImport => 'divelogs.de-import openen'; + + @override + String get divelogsSync_compare => 'Vergelijken'; + + @override + String get divelogsSync_comparing => 'Vergelijken met divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count duiken al gesynchroniseerd'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Ophalen: $count nieuw op divelogs.de'; + } + + @override + String get divelogsSync_pullReview => + 'Controleren en ophalen in de importwizard'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Versturen: $count duiken niet op divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'Selectie versturen'; + + @override + String get divelogsSync_pushing => 'Duiken versturen naar divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return '$count duiken naar divelogs.de verstuurd.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count duiken konden niet worden geconverteerd en zijn overgeslagen.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'Versturen gestopt na $count duiken: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Alles is gesynchroniseerd.'; + @override String get divelogs_signIn_title => 'Aanmelden bij divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 0773cdedf7..7ae08fd587 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8,6 +8,65 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get divelogsSync_title => 'Sincronização divelogs.de'; + + @override + String get divelogsSync_notConnected => + 'Nenhuma conta divelogs.de conectada ainda. Inicie uma importação para entrar.'; + + @override + String get divelogsSync_openImport => 'Abrir importação do divelogs.de'; + + @override + String get divelogsSync_compare => 'Comparar'; + + @override + String get divelogsSync_comparing => 'Comparando com divelogs.de...'; + + @override + String divelogsSync_matched(int count) { + return '$count mergulhos ja sincronizados'; + } + + @override + String divelogsSync_pullHeader(int count) { + return 'Baixar: $count novos no divelogs.de'; + } + + @override + String get divelogsSync_pullReview => + 'Revisar e baixar no assistente de importação'; + + @override + String divelogsSync_pushHeader(int count) { + return 'Enviar: $count mergulhos que não estão no divelogs.de'; + } + + @override + String get divelogsSync_pushSelected => 'Enviar selecionados'; + + @override + String get divelogsSync_pushing => 'Enviando mergulhos para divelogs.de...'; + + @override + String divelogsSync_pushDone(int count) { + return '$count mergulhos enviados para divelogs.de.'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count mergulhos não puderam ser convertidos e foram ignorados.'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return 'Envio interrompido após $count mergulhos: $error'; + } + + @override + String get divelogsSync_nothingToSync => 'Tudo está sincronizado.'; + @override String get divelogs_signIn_title => 'Iniciar sessão em divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index f3bfd872da..b60bea200d 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8,6 +8,63 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get divelogsSync_title => 'divelogs.de 同步'; + + @override + String get divelogsSync_notConnected => '尚未连接 divelogs.de 账户。请开始导入以登录。'; + + @override + String get divelogsSync_openImport => '打开 divelogs.de 导入'; + + @override + String get divelogsSync_compare => '比较'; + + @override + String get divelogsSync_comparing => '正在与 divelogs.de 比较...'; + + @override + String divelogsSync_matched(int count) { + return '$count 条潜水记录已同步'; + } + + @override + String divelogsSync_pullHeader(int count) { + return '拉取:divelogs.de 上有 $count 条新记录'; + } + + @override + String get divelogsSync_pullReview => '在导入向导中查看并拉取'; + + @override + String divelogsSync_pushHeader(int count) { + return '推送:$count 条潜水记录不在 divelogs.de 上'; + } + + @override + String get divelogsSync_pushSelected => '推送所选'; + + @override + String get divelogsSync_pushing => '正在向 divelogs.de 推送潜水记录...'; + + @override + String divelogsSync_pushDone(int count) { + return '已向 divelogs.de 推送 $count 条潜水记录。'; + } + + @override + String divelogsSync_pushSkipped(int count) { + return '$count 条潜水记录无法转换,已跳过。'; + } + + @override + String divelogsSync_pushFailedPartial(int count, String error) { + return '推送在 $count 条记录后停止:$error'; + } + + @override + String get divelogsSync_nothingToSync => '所有记录均已同步。'; + @override String get divelogs_signIn_title => '登录 divelogs.de'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 9aedfd136d..551197410f 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "divelogs.de-synchronisatie", + "divelogsSync_notConnected": "Er is nog geen divelogs.de-account gekoppeld. Start een import om aan te melden.", + "divelogsSync_openImport": "divelogs.de-import openen", + "divelogsSync_compare": "Vergelijken", + "divelogsSync_comparing": "Vergelijken met divelogs.de...", + "divelogsSync_matched": "{count} duiken al gesynchroniseerd", + "divelogsSync_pullHeader": "Ophalen: {count} nieuw op divelogs.de", + "divelogsSync_pullReview": "Controleren en ophalen in de importwizard", + "divelogsSync_pushHeader": "Versturen: {count} duiken niet op divelogs.de", + "divelogsSync_pushSelected": "Selectie versturen", + "divelogsSync_pushing": "Duiken versturen naar divelogs.de...", + "divelogsSync_pushDone": "{count} duiken naar divelogs.de verstuurd.", + "divelogsSync_pushSkipped": "{count} duiken konden niet worden geconverteerd en zijn overgeslagen.", + "divelogsSync_pushFailedPartial": "Versturen gestopt na {count} duiken: {error}", + "divelogsSync_nothingToSync": "Alles is gesynchroniseerd.", "divelogs_signIn_title": "Aanmelden bij divelogs.de", "divelogs_signIn_username": "Gebruikersnaam", "divelogs_signIn_password": "Wachtwoord", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 5c7b258310..19ef2d4e58 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "Sincronização divelogs.de", + "divelogsSync_notConnected": "Nenhuma conta divelogs.de conectada ainda. Inicie uma importação para entrar.", + "divelogsSync_openImport": "Abrir importação do divelogs.de", + "divelogsSync_compare": "Comparar", + "divelogsSync_comparing": "Comparando com divelogs.de...", + "divelogsSync_matched": "{count} mergulhos ja sincronizados", + "divelogsSync_pullHeader": "Baixar: {count} novos no divelogs.de", + "divelogsSync_pullReview": "Revisar e baixar no assistente de importação", + "divelogsSync_pushHeader": "Enviar: {count} mergulhos que não estão no divelogs.de", + "divelogsSync_pushSelected": "Enviar selecionados", + "divelogsSync_pushing": "Enviando mergulhos para divelogs.de...", + "divelogsSync_pushDone": "{count} mergulhos enviados para divelogs.de.", + "divelogsSync_pushSkipped": "{count} mergulhos não puderam ser convertidos e foram ignorados.", + "divelogsSync_pushFailedPartial": "Envio interrompido após {count} mergulhos: {error}", + "divelogsSync_nothingToSync": "Tudo está sincronizado.", "divelogs_signIn_title": "Iniciar sessão em divelogs.de", "divelogs_signIn_username": "Nome de usuário", "divelogs_signIn_password": "Senha", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index e5a02f8979..2cfa8a0170 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1,4 +1,19 @@ { + "divelogsSync_title": "divelogs.de 同步", + "divelogsSync_notConnected": "尚未连接 divelogs.de 账户。请开始导入以登录。", + "divelogsSync_openImport": "打开 divelogs.de 导入", + "divelogsSync_compare": "比较", + "divelogsSync_comparing": "正在与 divelogs.de 比较...", + "divelogsSync_matched": "{count} 条潜水记录已同步", + "divelogsSync_pullHeader": "拉取:divelogs.de 上有 {count} 条新记录", + "divelogsSync_pullReview": "在导入向导中查看并拉取", + "divelogsSync_pushHeader": "推送:{count} 条潜水记录不在 divelogs.de 上", + "divelogsSync_pushSelected": "推送所选", + "divelogsSync_pushing": "正在向 divelogs.de 推送潜水记录...", + "divelogsSync_pushDone": "已向 divelogs.de 推送 {count} 条潜水记录。", + "divelogsSync_pushSkipped": "{count} 条潜水记录无法转换,已跳过。", + "divelogsSync_pushFailedPartial": "推送在 {count} 条记录后停止:{error}", + "divelogsSync_nothingToSync": "所有记录均已同步。", "divelogs_signIn_title": "登录 divelogs.de", "divelogs_signIn_username": "用户名", "divelogs_signIn_password": "密码", diff --git a/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart b/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart new file mode 100644 index 0000000000..cad894d4d2 --- /dev/null +++ b/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/data/repositories/connected_accounts_repository.dart'; +import 'package:submersion/core/providers/account_providers.dart'; +import 'package:submersion/core/services/accounts/account_kind.dart'; +import 'package:submersion/core/services/accounts/account_credentials_store.dart'; +import 'package:submersion/core/services/divelogs/divelogs_credentials.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/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/divelogs_sync/presentation/pages/divelogs_sync_page.dart'; +import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../helpers/test_database.dart'; +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + final diver = Diver( + id: 'diver-1', + name: 'Eric', + createdAt: DateTime(2020), + updatedAt: DateTime(2020), + ); + + late InMemoryKeychain keychain; + late AccountCredentialsStore credentialsStore; + late SharedPreferences prefs; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + keychain = InMemoryKeychain(); + credentialsStore = AccountCredentialsStore(storage: keychain); + }); + + tearDown(() => tearDownTestDatabase()); + + Widget host(http.Client mockClient) => ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + accountCredentialsStoreProvider.overrideWithValue(credentialsStore), + divelogsHttpClientProvider.overrideWithValue(mockClient), + allDiversProvider.overrideWith((ref) async => [diver]), + currentDiverProvider.overrideWith((ref) async => diver), + ], + child: const MaterialApp( + locale: Locale('en'), + themeAnimationDuration: Duration.zero, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DivelogsSyncPage(), + ), + ); + + Map divelistEntry(int id, String date, String time) => { + 'id': id, + 'date': date, + 'time': time, + 'duration': 2700, + 'maxdepth': 18, + }; + + /// Seeds a signed-in divelogs account bound to diver-1. Runs BEFORE the + /// page is pumped (the page resolves the account in initState). + Future seedAccount() async { + await DiverRepository().createDiver(diver); + final account = await ConnectedAccountsRepository().create( + kind: AccountKind.divelogs, + label: 'divelogs.de', + accountIdentifier: 'eric', + diverId: 'diver-1', + ); + await credentialsStore.write( + account.id, + const DivelogsCredentials( + username: 'eric', + password: 'p', + bearerToken: 'jwt', + ).toJsonString(), + ); + } + + Future seedLocalDive( + String id, + DateTime at, { + int? diveNumber, + Duration runtime = const Duration(seconds: 2700), + double maxDepth = 18, + }) async { + await DiveRepository().createDive( + Dive( + id: id, + diverId: 'diver-1', + diveNumber: diveNumber, + dateTime: at, + entryTime: at, + runtime: runtime, + maxDepth: maxDepth, + ), + ); + } + + testWidgets('shows connect prompt when no account exists', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget( + host(MockClient((req) async => fail('no network expected'))), + ); + await tester.pumpAndSettle(); + }); + + expect( + find.text( + 'No divelogs.de account is connected yet. Start an import to sign in.', + ), + findsOneWidget, + ); + expect(find.text('Open divelogs.de import'), findsOneWidget); + }); + + testWidgets('compare renders pull/push/matched sections', (tester) async { + final client = MockClient((req) async { + if (req.url.path == '/api/divelist') { + return http.Response( + jsonEncode([ + divelistEntry(1, '2022-09-03', '10:00:00'), + divelistEntry(2, '2023-01-15', '11:00:00'), + ]), + 200, + ); + } + fail('unexpected request ${req.url}'); + }); + + await tester.runAsync(() async { + await seedAccount(); + await seedLocalDive('local-matched', DateTime.utc(2022, 9, 3, 10)); + await seedLocalDive( + 'local-only', + DateTime.utc(2022, 10, 1, 9), + diveNumber: 42, + runtime: const Duration(seconds: 3000), + maxDepth: 22, + ); + await tester.pumpWidget(host(client)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Compare')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + }); + + expect(find.text('1 dives already in sync'), findsOneWidget); + expect(find.text('Pull: 1 new on divelogs.de'), findsOneWidget); + expect(find.text('Push: 1 dives not on divelogs.de'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsOneWidget); + }); + + testWidgets('push posts selected dives and reports the count', ( + tester, + ) async { + var divelistCalls = 0; + List? postedBody; + final client = MockClient((req) async { + if (req.url.path == '/api/divelist') { + divelistCalls++; + if (divelistCalls >= 2) { + // After the push the remote side has the dive too. + return http.Response( + jsonEncode([divelistEntry(9, '2022-10-01', '09:00:00')]), + 200, + ); + } + return http.Response(jsonEncode([]), 200); + } + if (req.url.path == '/api/dives' && req.method == 'POST') { + postedBody = jsonDecode(req.body) as List; + return http.Response('{}', 200); + } + fail('unexpected request ${req.method} ${req.url}'); + }); + + await tester.runAsync(() async { + await seedAccount(); + await seedLocalDive( + 'local-only', + DateTime.utc(2022, 10, 1, 9), + runtime: const Duration(seconds: 3000), + maxDepth: 22, + ); + await tester.pumpWidget(host(client)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Compare')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Push selected')); + await tester.tap(find.text('Push selected')); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + }); + + expect(postedBody, isNotNull); + expect(postedBody, hasLength(1)); + expect(divelistCalls, 2, reason: 'push triggers an automatic re-compare'); + expect(find.textContaining('Pushed 1 dives'), findsOneWidget); + }); +} From af042591ff01a38a33f296ff08cd6aa74d5eb56d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:40:05 -0400 Subject: [PATCH 21/35] docs: add divelogs.de sync phase 3 implementation plan --- .../plans/2026-07-17-divelogs-sync-phase3.md | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-divelogs-sync-phase3.md diff --git a/docs/superpowers/plans/2026-07-17-divelogs-sync-phase3.md b/docs/superpowers/plans/2026-07-17-divelogs-sync-phase3.md new file mode 100644 index 0000000000..18ec353400 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-divelogs-sync-phase3.md @@ -0,0 +1,528 @@ +# divelogs.de Sync — Phase 3 (Gear + Certifications) 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:** Pull divelogs.de gear and certifications into Submersion through the import wizard (with dive-to-gear links), and push local equipment/certifications back from the sync page, create-only in both directions. + +**Architecture:** Pull rides the existing universal-import pipeline — the divelogs fetch simply adds `ImportEntityType.equipment` and `ImportEntityType.certifications` entities to the payload (per-item review, dedup by `name|type` and `name|agency` compound keys, and `equipmentRefs` dive-linking all already exist). Push adds a pure-function `GearCertSyncPlanner` plus a push service that creates unmatched gear/certs remotely; because `POST /gear` does not document a returned id, pushed dives resolve `gearitems` ids by re-fetching `GET /gear` after gear push. + +**Tech Stack:** Same as Phases 1–2. No schema migration. + +**Spec:** `docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md` (Phase 3 section). + +## Global Constraints + +- Same as Phases 1–2 (metric units, wall-clock UTC, `dart format .` clean, no emojis, no commit attribution, l10n into all 11 locales + `flutter gen-l10n`, per-file tests, `--no-verify` push after in-worktree verification). +- divelogs.de API (verified from the OpenAPI spec): + - `GET /gear` list; `POST /gear` body `{name, geartype (number), purchasedate, last_servicedate, discarddate, servicedives, servicemonths, standard, add_to_existing}` — response documents only "Success", NO created id. + - `GET /geartypes` (unauthenticated) returns the geartype id/name reference list (exact shape unconfirmed — parse tolerantly). + - `GET /certifications` returns `[{id, name, date, org, typ, scans}]`; `POST /certifications` is **multipart/form-data** with mandatory `name` + `date` (YYYY-MM-DD), optional `org`; response includes the created `id`. + - Dives carry `gearitems: [numeric ids]`. +- Create-only both directions; no updates or deletes; scans/photos stay Phase 4. +- Pull-side per-endpoint resilience: a failure fetching gear or certifications degrades to a payload warning — it must never abort the dive pull. +- Sync-page push for gear/certs is a single "Sync gear & certifications" action with counts (no per-item checkboxes — low-risk reference data; per-item review exists on the pull side via the wizard). Per-dive checkboxes remain for dives. This is a deliberate simplification of the spec's sketch; record it in code comments. + +--- + +### Task 1: Gear/certification models + API endpoints + +**Files:** +- Modify: `lib/core/services/divelogs/divelogs_models.dart` +- Modify: `lib/core/services/divelogs/divelogs_api_client.dart` +- Test: `test/core/services/divelogs/divelogs_models_test.dart` (extend) +- Test: `test/core/services/divelogs/divelogs_api_client_test.dart` (extend) + +**Interfaces:** +- Consumes: existing `_send`/`_get`/`_decode` plumbing and the `_asDouble/_asInt/_asNonEmptyString` helpers. +- Produces: + - `DivelogsDive` gains `final List gearItemIds;` (parsed from `json['gearitems']`, each element stringified; default `const []`). + - `class DivelogsGearItem { final String id; final String name; final int? geartypeId; final DateTime? purchaseDate; final DateTime? lastServiceDate; final DateTime? discardDate; static DivelogsGearItem? fromJson(Map); }` — null when id or name unusable; dates parsed as wall-clock UTC via `DateTime.tryParse('${value}T00:00:00Z')`. + - `class DivelogsCertification { final String? id; final String name; final DateTime? date; final String? org; static DivelogsCertification? fromJson(Map); }` — null when name missing. + - On the client: `Future> getGear()`, `Future> getGeartypes()` (tolerant: accepts `[{id, name}]`, `{geartypes: [...]}`, or `{"1": "Regulator"}` map form; unauthenticated but sent through `_get` anyway — the bearer header is harmless), `Future> getCertifications()`, `Future postGear(Map gear)` (JSON POST via `_send`), `Future postCertification({required String name, required String date, String? org})` (**multipart** POST with the same 401-invalidate-retry-once semantics — see Step 3). + +- [ ] **Step 1: Write the failing model tests** — append to `divelogs_models_test.dart`: + +```dart +group('DivelogsGearItem', () { + test('parses fields with wall-clock UTC dates', () { + final gear = DivelogsGearItem.fromJson({ + 'id': 45, + 'name': 'Apex XTX50', + 'geartype': 1, + 'purchasedate': '2007-05-12', + 'last_servicedate': '2024-01-02', + 'discarddate': null, + })!; + expect(gear.id, '45'); + expect(gear.name, 'Apex XTX50'); + expect(gear.geartypeId, 1); + expect(gear.purchaseDate, DateTime.utc(2007, 5, 12)); + expect(gear.lastServiceDate, DateTime.utc(2024, 1, 2)); + expect(gear.discardDate, isNull); + }); + + test('returns null without id or name', () { + expect(DivelogsGearItem.fromJson({'name': 'X'}), isNull); + expect(DivelogsGearItem.fromJson({'id': 1}), isNull); + }); +}); + +group('DivelogsCertification', () { + test('parses fields', () { + final cert = DivelogsCertification.fromJson({ + 'id': 123, + 'name': 'Open Water Diver', + 'date': '2022-06-15', + 'org': 'PADI', + })!; + expect(cert.id, '123'); + expect(cert.name, 'Open Water Diver'); + expect(cert.date, DateTime.utc(2022, 6, 15)); + expect(cert.org, 'PADI'); + }); + + test('returns null without a name', () { + expect(DivelogsCertification.fromJson({'id': 1}), isNull); + }); +}); + +test('DivelogsDive parses gearitems as string ids', () { + final dive = DivelogsDive.fromJson({ + 'id': 1, + 'date': '2022-09-03', + 'time': '10:00:00', + 'duration': 60, + 'maxdepth': 5, + 'gearitems': [45, 62], + }); + expect(dive.gearItemIds, ['45', '62']); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `flutter test test/core/services/divelogs/divelogs_models_test.dart`, expect compile FAIL. + +- [ ] **Step 3: Implement models and endpoints** + +Models (append; also add `gearItemIds` to `DivelogsDive` — field, constructor param `this.gearItemIds = const []`, and in `fromJson`: `gearItemIds: json['gearitems'] is List ? [for (final g in json['gearitems'] as List) '$g'] : const []`): + +```dart +DateTime? _asUtcDate(Object? v) { + final s = _asNonEmptyString(v); + return s == null ? null : DateTime.tryParse('${s}T00:00:00Z'); +} + +/// One row of GET /gear. Tolerant: unusable rows yield null. +class DivelogsGearItem { + final String id; + final String name; + final int? geartypeId; + final DateTime? purchaseDate; + final DateTime? lastServiceDate; + final DateTime? discardDate; + + const DivelogsGearItem({ + required this.id, + required this.name, + this.geartypeId, + this.purchaseDate, + this.lastServiceDate, + this.discardDate, + }); + + static DivelogsGearItem? fromJson(Map json) { + final rawId = json['id'] ?? json['gear_id']; + final name = _asNonEmptyString(json['name']); + if (rawId == null || name == null) return null; + return DivelogsGearItem( + id: '$rawId', + name: name, + geartypeId: _asInt(json['geartype']), + purchaseDate: _asUtcDate(json['purchasedate']), + lastServiceDate: _asUtcDate(json['last_servicedate']), + discardDate: _asUtcDate(json['discarddate']), + ); + } +} + +/// One row of GET /certifications. Tolerant: unusable rows yield null. +class DivelogsCertification { + final String? id; + final String name; + final DateTime? date; + final String? org; + + const DivelogsCertification({ + this.id, + required this.name, + this.date, + this.org, + }); + + static DivelogsCertification? fromJson(Map json) { + final name = _asNonEmptyString(json['name']); + if (name == null) return null; + final rawId = json['id']; + return DivelogsCertification( + id: rawId == null ? null : '$rawId', + name: name, + date: _asUtcDate(json['date']), + org: _asNonEmptyString(json['org']), + ); + } +} +``` + +Client — a private list-extraction helper plus the five endpoints. For multipart with retry, mirror `_send`'s loop but build a fresh `http.MultipartRequest` each attempt: + +```dart +List _rows(Object? decoded, String endpoint, List listKeys) { + if (decoded is List) return decoded; + if (decoded is Map) { + for (final key in listKeys) { + if (decoded[key] is List) return decoded[key] as List; + } + } + throw DivelogsApiException(0, 'Unexpected $endpoint response'); +} + +Future> getGear() async { + final response = await _get('/gear'); + final rows = _rows(_decode(response.body, '/gear'), '/gear', const [ + 'gear', + 'gearitems', + ]); + return [ + for (final row in rows) + if (row is Map) + ...?_maybe(DivelogsGearItem.fromJson(Map.from(row))), + ]; +} + +Future> getCertifications() async { + final response = await _get('/certifications'); + final rows = _rows( + _decode(response.body, '/certifications'), + '/certifications', + const ['certifications'], + ); + return [ + for (final row in rows) + if (row is Map) + ...?_maybe( + DivelogsCertification.fromJson(Map.from(row)), + ), + ]; +} + +List? _maybe(T? value) => value == null ? null : [value]; + +/// Geartype reference list: id -> display name. Accepts array-of-objects, +/// wrapped, or id->name map forms (shape unconfirmed, spec open question). +Future> getGeartypes() async { + final response = await _get('/geartypes'); + final decoded = _decode(response.body, '/geartypes'); + final result = {}; + if (decoded is Map && decoded.values.every((v) => v is String)) { + decoded.forEach((k, v) { + final id = int.tryParse('$k'); + if (id != null) result[id] = v as String; + }); + return result; + } + final rows = _rows(decoded, '/geartypes', const ['geartypes']); + for (final row in rows) { + if (row is Map) { + final id = row['id']; + final name = row['name']; + if (id is num && name is String) result[id.toInt()] = name; + } + } + return result; +} + +Future postGear(Map gear) async { + await _send('/gear', method: 'POST', jsonBody: gear); +} + +Future postCertification({ + required String name, + required String date, + String? org, +}) async { + var authRetried = false; + while (true) { + final token = await _getBearerToken(); + final request = http.MultipartRequest( + 'POST', + _baseUri.replace(path: '${_baseUri.path}/certifications'), + )..headers['Authorization'] = 'Bearer $token'; + request.fields['name'] = name; + request.fields['date'] = date; + if (org != null) request.fields['org'] = org; + final http.Response response; + try { + response = await http.Response.fromStream(await _http.send(request)); + } on Exception { + throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + _onTokenRejected(); + if (!authRetried) { + authRetried = true; + continue; + } + throw const DivelogsApiException( + 401, + 'divelogs.de sign-in expired. Sign in again in Settings.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsApiException( + response.statusCode, + 'divelogs.de API error ${response.statusCode}', + ); + } + return; + } +} +``` + +- [ ] **Step 4: Write failing client tests** — append to `divelogs_api_client_test.dart` (reuse the `client(...)` helper): `getGear` parses an array and skips a no-name row; `getGeartypes` accepts both `[{id, name}]` and `{"1": "Regulator"}` forms; `getCertifications` parses the documented array; `postGear` sends a JSON POST to `/api/gear`; `postCertification` sends multipart fields `name`/`date`/`org` (assert `captured.headers['Content-Type']` starts with `multipart/form-data` and the body contains the field values) and retries once on 401 (tokens `['t1','t2']`, count calls == 2). Write these as full `test(...)` blocks following the exact style of the existing `postDives` tests in the same file. + +- [ ] **Step 5: Run to green** — `flutter test test/core/services/divelogs/`, expect PASS. + +- [ ] **Step 6: Commit** + +```bash +dart format . +git add -A lib/core/services/divelogs test/core/services/divelogs +git commit -m "feat: add divelogs.de gear, geartype, and certification endpoints" +``` + +--- + +### Task 2: Reference mappers (geartype ↔ EquipmentType, org → agency, name → level) + +**Files:** +- Create: `lib/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart` +- Test: `test/features/divelogs_sync/data/mappers/divelogs_reference_mappers_test.dart` + +**Interfaces:** +- Consumes: `EquipmentType`, `CertificationAgency`, `CertificationLevel` (`lib/core/constants/enums.dart`). +- Produces (all pure, top-level or static on `abstract final class DivelogsReferenceMappers`): + - `static EquipmentType equipmentTypeForGeartypeName(String? name)` — keyword table over the lowercased geartype name, English AND German synonyms (divelogs.de's home locale): regulator/lungenautomat/atemregler → `regulator`; bcd/jacket/wing/tarierweste → `bcd`; drysuit/trocken → `drysuit`; suit/anzug/wetsuit/nass → `wetsuit`; fin/flosse → `fins`; mask/maske → `mask`; computer → `computer`; tank/cylinder/flasche → `tank`; weight/blei → `weights`; light/lamp/lampe → `light`; camera/kamera → `camera`; boot/füßling/fussling → `boots`; glove/handschuh → `gloves`; hood/haube → `hood`; knife/messer → `knife`; reel → `reel`; smb/boje → `smb`; anything else/null → `other`. Check drysuit BEFORE wetsuit (both contain "suit"). + - `static String? geartypeNameForEquipmentType(EquipmentType type, Map geartypes)` → returns the FIRST remote geartype name whose mapped `equipmentTypeForGeartypeName` equals `type`, else null; and `static int? geartypeIdForEquipmentType(EquipmentType type, Map geartypes)` same but returning the id. + - `static CertificationAgency agencyForOrg(String? org)` — trim/lowercase, match against `CertificationAgency.values` by `.name` and `.displayName.toLowerCase()`; else `CertificationAgency.other`. + - `static CertificationLevel? levelForName(String name)` — lowercased trim-match against each `CertificationLevel`'s `displayName` (verify the getter name in `enums.dart`; it is the human string used in dropdowns), else null. The original text is preserved in the certification's `name` field regardless. + +- [ ] **Step 1: Write the failing test** — cover: `'Regulator'`/`'Atemregler'` → regulator; `'Trockentauchanzug'` → drysuit (not wetsuit); `'Nassanzug'` → wetsuit; `null`/`'Gadget'` → other; round-trip `geartypeIdForEquipmentType(EquipmentType.bcd, {1: 'Regulator', 2: 'Jacket'})` → 2 and → null when nothing maps; `agencyForOrg('PADI')` → padi, `'ssi '` → ssi, `'Some Club'` → other, `null` → other; `levelForName('Open Water')`/`'open water'` → the corresponding level, `'Fancy Specialty XYZ'` → null. Write the complete test file in the established style. + +- [ ] **Step 2: Run red, implement, run green** — implementation is a keyword table (list of `(List keywords, EquipmentType type)` records iterated in order, drysuit keywords before wetsuit) plus the two enum matchers as specified. Complete code follows directly from the Interfaces block; no I/O, no state. + +- [ ] **Step 3: Commit** + +```bash +dart format . +git add -A lib/features/divelogs_sync test/features/divelogs_sync +git commit -m "feat: map divelogs.de geartypes and orgs onto domain enums" +``` + +--- + +### Task 3: Pull — gear + certifications into the import payload + +**Files:** +- Modify: `lib/features/universal_import/data/services/divelogs_import_service.dart` +- Modify: `lib/features/universal_import/data/services/divelogs_dive_mapper.dart` (dive maps gain `equipmentRefs`) +- Test: `test/features/universal_import/data/services/divelogs_import_service_test.dart` (extend), `test/features/universal_import/data/services/divelogs_dive_mapper_test.dart` (extend) + +**Interfaces:** +- Consumes: Task 1 endpoints/models, Task 2 mappers, `ImportEntityType.equipment`/`.certifications`, `EquipmentStatus`. +- Produces: + - `DivelogsDiveMapper.mapDive` adds `'equipmentRefs': [for (final id in dive.gearItemIds) gearKey(id)]` when `gearItemIds` is non-empty, with `static String gearKey(String id) => 'divelogs-gear-$id';`. + - `DivelogsImportService.fetchAllDives()` additionally fetches gear + geartypes + certifications and emits: + - Equipment maps: `{'uddfId': DivelogsDiveMapper.gearKey(g.id), 'name': g.name, 'type': DivelogsReferenceMappers.equipmentTypeForGeartypeName(geartypes[g.geartypeId]), 'purchaseDate': g.purchaseDate, 'lastServiceDate': g.lastServiceDate, 'status': g.discardDate != null ? EquipmentStatus.retired : EquipmentStatus.active, 'isActive': g.discardDate == null}` (null-valued optional keys omitted). + - Certification maps: `{'uddfId': 'divelogs-cert-${c.id ?? c.name}', 'name': c.name, 'agency': DivelogsReferenceMappers.agencyForOrg(c.org), 'issueDate': c.date, 'level': DivelogsReferenceMappers.levelForName(c.name)}` (null level/issueDate omitted). + - A `DivelogsApiException` from the gear, geartypes, or certifications fetch is caught per-endpoint and becomes an `ImportWarning(severity: warning, message: 'Gear could not be fetched from divelogs.de: ')` (respectively certifications); geartypes failure just means all types map to `other`. Dive fetching is never affected. + +- [ ] **Step 1: Write the failing tests** + +Mapper test additions: a dive with `gearItemIds: ['45', '62']` maps to `equipmentRefs: ['divelogs-gear-45', 'divelogs-gear-62']`; empty ids → no `equipmentRefs` key. + +Import-service test additions (extend the existing MockClient `service(...)` harness so the handler serves `/api/dives`, `/api/gear`, `/api/geartypes`, `/api/certifications`): +- Payload contains an equipment entity with `uddfId 'divelogs-gear-45'`, `type EquipmentType.regulator` (geartypes `{1: 'Regulator'}`, gear row geartype 1), `status EquipmentStatus.active`. +- A discarded gear row (`discarddate` set) maps to `status EquipmentStatus.retired` and `isActive false`. +- Payload contains a certification entity with `agency CertificationAgency.padi` for org `'PADI'` and `issueDate DateTime.utc(2022, 6, 15)`. +- A dive whose JSON has `gearitems: [45]` produces a dive map with `equipmentRefs ['divelogs-gear-45']`. +- A 500 on `/api/gear` yields a payload that still contains the dives plus one warning mentioning gear; certifications analogous. +- Existing tests must remain green (the wizard flow test in `divelogs_fetch_step_test.dart` serves only `/api/dives` — update its MockClient to also serve empty `[]` for the three new endpoints). + +- [ ] **Step 2: Run red, implement** + +In `fetchAllDives`, after the dives fetch: + +```dart +final warnings = []; +Map geartypes = const {}; +var gear = const []; +var certs = const []; +try { + geartypes = await _api.getGeartypes(); +} on DivelogsApiException { + // Types degrade to EquipmentType.other; not user-visible enough to warn. +} +try { + gear = await _api.getGear(); +} on DivelogsApiException catch (e) { + warnings.add(ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'Gear could not be fetched from divelogs.de: ${e.message}', + )); +} +try { + certs = await _api.getCertifications(); +} on DivelogsApiException catch (e) { + warnings.add(ImportWarning( + severity: ImportWarningSeverity.warning, + message: + 'Certifications could not be fetched from divelogs.de: ${e.message}', + )); +} +``` + +then build the entity maps per the Interfaces block and merge `warnings` with the existing skipped-dives warning. Keep the method name `fetchAllDives`. + +- [ ] **Step 3: Run green** — `flutter test test/features/universal_import/data/services test/features/import_wizard`, expect PASS. + +- [ ] **Step 4: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: pull divelogs.de gear and certifications through the import wizard" +``` + +--- + +### Task 4: `GearCertSyncPlanner` — push-side diff + +**Files:** +- Create: `lib/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart` +- Test: `test/features/divelogs_sync/domain/services/gear_cert_sync_planner_test.dart` + +**Interfaces:** +- Consumes: `DivelogsGearItem`/`DivelogsCertification` (Task 1), `EquipmentItem`, `Certification` domain entities. +- Produces: + - `class GearCertSyncPlan { final List pushGear; final List pushCerts; final int matchedGear; final int matchedCerts; final int pullGear; final int pullCerts; final int certsMissingDate; }` (pull counts are informational — pull itself happens in the wizard; `certsMissingDate` counts local certs excluded from push for lacking the API-mandatory date). + - `class GearCertSyncPlanner { const GearCertSyncPlanner(); GearCertSyncPlan plan({required List remoteGear, required List remoteCerts, required List localGear, required List localCerts}); }` +- Matching rules (create-only, name-keyed): + - Gear: normalized name equality (`name.trim().toLowerCase()`); one-to-one (a remote name consumes one local item). Local unmatched → `pushGear`; remote unmatched → `pullGear` count. + - Certifications: normalized name equality AND, when BOTH sides have a date, same calendar date (`y/m/d` of the wall-clock UTC values). Local unmatched → `pushCerts`; remote unmatched → `pullCerts` count. + - Retired/lost local gear is still matchable but never pushed (`pushGear` excludes items with `status` in `{retired, lost}` or `isActive == false`). + - Local certs without an `issueDate` are excluded from `pushCerts` (the API requires `date`) — track them as `final int certsMissingDate;` on the plan for the summary line. + +- [ ] **Step 1: Write the failing test** — cases: matched gear by case-insensitive name; local-only active gear → pushGear; retired local gear matched but never pushed; remote-only gear → pullGear count; cert matched by name+date; same name different date → both push and pull; local cert without issueDate → excluded from pushCerts and counted in certsMissingDate. Write the full test file (fixtures: `EquipmentItem(id:, name:, type: EquipmentType.regulator, status:, isActive:)`, `Certification(id:, name:, agency: CertificationAgency.padi, issueDate:, createdAt: now, updatedAt: now)`). + +- [ ] **Step 2: Run red, implement** — straightforward set arithmetic over normalized-name maps (`Map>` to keep one-to-one consumption); ~80 lines, pure. + +- [ ] **Step 3: Run green, commit** + +```bash +dart format . +git add -A lib/features/divelogs_sync test/features/divelogs_sync +git commit -m "feat: add create-only gear and certification sync planner" +``` + +--- + +### Task 5: Gear/cert push service + `gearitems` on pushed dives + +**Files:** +- Create: `lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart` +- Modify: `lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart` +- Modify: `lib/features/divelogs_sync/domain/services/divelogs_push_service.dart` +- Test: `test/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service_test.dart`, extend `divelogs_export_mapper_test.dart` and `divelogs_push_service_test.dart` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces: + - `class GearCertPushResult { final int gearPushed; final int certsPushed; final String? error; bool get failed => error != null; }` + - `class DivelogsGearCertPushService { DivelogsGearCertPushService({required DivelogsApiClient api}); Future push({required List gear, required List certs, required Map geartypes}); }` — for each gear item: `postGear({'name': item.name, if (geartypeId != null) 'geartype': geartypeId, if (item.purchaseDate != null) 'purchasedate': , if (item.lastServiceDate != null) 'last_servicedate': })` with `geartypeId = DivelogsReferenceMappers.geartypeIdForEquipmentType(item.type, geartypes)`; for each cert: `postCertification(name: cert.name, date: , org: cert.agency == CertificationAgency.other ? null : cert.agency.displayName)`. Sequential; a `DivelogsApiException` stops the run and reports partial counts (same convergence argument as dives). + - `DivelogsExportMapper.mapDive` gains an optional parameter: `Map? mapDive(Dive dive, {Map remoteGearIdByName = const {}})` — when the dive has linked `equipment`, emit `'gearitems': [int ids]` for every item whose `name.trim().toLowerCase()` appears in the map (values are the remote id strings, parsed to int; unparseable ids skipped). + - `DivelogsPushService.push` gains the same passthrough parameter `{Map remoteGearIdByName = const {}}` and forwards it to the mapper. + - Shared date helper: extract Phase 2's `date`/`two()` formatting into a top-level `String divelogsDate(DateTime d)` in `divelogs_export_mapper.dart` and reuse it in the gear/cert service. + +- [ ] **Step 1: Write the failing tests** — gear service: pushes two gear items and one cert with correct bodies (capture requests; assert geartype id resolved via `{2: 'Jacket'}` for a `bcd` item, purchasedate formatted `yyyy-MM-dd`, cert multipart fields, org `'PADI'`); a 500 on the second call stops and reports `gearPushed == 1`. Mapper: a dive with `equipment: [EquipmentItem(name: 'Apex XTX50', ...)]` and `remoteGearIdByName: {'apex xtx50': '45'}` emits `gearitems: [45]`; without the map entry, no `gearitems` key. Write full tests in the established style. + +- [ ] **Step 2: Run red, implement, run green** — `flutter test test/features/divelogs_sync`, expect PASS. + +- [ ] **Step 3: Commit** + +```bash +dart format . +git add -A lib/features/divelogs_sync test/features/divelogs_sync +git commit -m "feat: push gear and certifications to divelogs.de with dive gear links" +``` + +--- + +### Task 6: Sync page — gear & certifications section + +**Files:** +- Modify: `lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart` +- Modify: `lib/l10n/arb/app_en.arb` + all 10 non-English arb files +- Test: extend `test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart` + +**Interfaces:** +- Consumes: Tasks 1, 4, 5; `equipmentRepositoryProvider` (`lib/features/equipment/presentation/providers/equipment_providers.dart`, `getAllEquipment({diverId})`), `certificationRepositoryProvider` (`lib/features/certifications/presentation/providers/certification_providers.dart`, `getAllCertifications({diverId})`). +- Produces: the page's `_compare()` additionally fetches remote gear/certs/geartypes and local equipment/certs, stores `GearCertSyncPlan? _gearCertPlan` and `Map _geartypes`; `_push()` fetches remote gear once beforehand and passes `remoteGearIdByName` (built as `{g.name.trim().toLowerCase(): g.id}`) into `DivelogsPushService.push`; a new `_pushGearCerts()` runs `DivelogsGearCertPushService` then re-runs `_compare()`. + +Page behavior additions (plan view): +- A "Gear & certifications" section after the dive sections showing: matched counts line, pull counts line (informational, wizard link already exists above), and — when `pushGear`/`pushCerts` are non-empty — a summary line "Push: N gear items, M certifications" plus a `FilledButton.tonal` "Sync gear & certifications" invoking `_pushGearCerts()`. When `certsMissingDate > 0`, a caption noting how many certifications need an issue date before they can be pushed. +- Gear/cert fetch failures during `_compare` must not break the dive compare: wrap in try/catch and render the section with an inline error line instead. + +New l10n keys (en; translate into all 10 non-English locales with proper diacritics, same insertion-script approach; placeholders typed like Phase 2's): + +```json +"divelogsSync_gearCertHeader": "Gear & certifications", +"divelogsSync_gearCertMatched": "{gear} gear items and {certs} certifications already in sync", +"divelogsSync_gearCertPush": "Push: {gear} gear items, {certs} certifications", +"divelogsSync_gearCertPushButton": "Sync gear & certifications", +"divelogsSync_gearCertPushDone": "Pushed {gear} gear items and {certs} certifications.", +"divelogsSync_gearCertPushFailed": "Gear/certification push stopped: {error}", +"divelogsSync_certsMissingDate": "{count} certifications need an issue date before they can be pushed.", +"divelogsSync_gearCertUnavailable": "Gear and certifications could not be compared: {error}" +``` + +- [ ] **Step 1: Extend the widget test** — the existing MockClient handlers gain `/api/gear`, `/api/geartypes`, `/api/certifications` routes. Add: (a) compare shows the gear/cert section with push counts when a local-only equipment item + cert exist (seed via `EquipmentRepository().createEquipment(...)` and `CertificationRepository().createCertification(...)` with `diverId: 'diver-1'` before pumping); (b) tapping "Sync gear & certifications" POSTs to `/api/gear` and `/api/certifications` and re-compares. Write the tests fully, following the file's existing seeding style. + +- [ ] **Step 2: Run red, implement page + l10n, `flutter gen-l10n`, run green** — `flutter test test/features/divelogs_sync test/l10n && flutter analyze`. + +- [ ] **Step 3: Commit** + +```bash +dart format . +git add -A lib test +git commit -m "feat: sync gear and certifications from the divelogs.de sync page" +``` + +--- + +### Task 7: Verification sweep + +- [ ] **Step 1:** `dart format . && flutter analyze` — no changes, no issues. +- [ ] **Step 2:** `flutter test test/core/services/divelogs test/features/divelogs_sync test/features/import_wizard test/features/universal_import/data/services` — all PASS. +- [ ] **Step 3:** Full suite in the background. Known pre-existing flake: isolated backup/setup-wizard failures that pass in isolation (see memory `flaky-backup-tests-full-suite`) — verify any failure against that pattern before treating it as real. +- [ ] **Step 4:** Commit any fixes; do not push (user-triggered step; branch carries PR #603). + +## Deferred (do NOT build now) + +- Pictures/scans (Phase 4) — `scans` on certifications and `POST /pictures` stay untouched. +- Gear service records/schedules mapping (`servicedives`/`servicemonths`) — Submersion's service tracking is richer; create-only name-level sync only. +- Per-item push checkboxes for gear/certs (deliberate simplification; revisit on user feedback). + +## Open assumptions (confirm with Rainer, do not block) + +- `GET /gear` rows carry `id` + `name`; `GET /geartypes` returns an id/name list; `POST /gear` returns no id (we re-fetch after push). +- `gearitems` on `POST /dives` accepts the numeric ids from `GET /gear`. +- Certification `org` free-text matches common agency names. From 06300f28eacd0bfec16958a3d33288b3c7f2ca06 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:45:59 -0400 Subject: [PATCH 22/35] feat: add divelogs.de gear, geartype, and certification endpoints --- .../divelogs/divelogs_api_client.dart | 114 ++++++++++++++++ .../services/divelogs/divelogs_models.dart | 70 ++++++++++ .../divelogs/divelogs_api_client_test.dart | 129 ++++++++++++++++++ .../divelogs/divelogs_models_test.dart | 55 ++++++++ 4 files changed, 368 insertions(+) diff --git a/lib/core/services/divelogs/divelogs_api_client.dart b/lib/core/services/divelogs/divelogs_api_client.dart index e00c217891..fc845c25f8 100644 --- a/lib/core/services/divelogs/divelogs_api_client.dart +++ b/lib/core/services/divelogs/divelogs_api_client.dart @@ -103,6 +103,120 @@ class DivelogsApiClient { await _send('/dives', method: 'POST', jsonBody: dives); } + Future> getGear() async { + final response = await _get('/gear'); + final rows = _rows(_decode(response.body, '/gear'), '/gear', const [ + 'gear', + 'gearitems', + ]); + return [ + for (final row in rows) + if (row is Map) + ...?_maybe(DivelogsGearItem.fromJson(Map.from(row))), + ]; + } + + Future> getCertifications() async { + final response = await _get('/certifications'); + final rows = _rows( + _decode(response.body, '/certifications'), + '/certifications', + const ['certifications'], + ); + return [ + for (final row in rows) + if (row is Map) + ...?_maybe( + DivelogsCertification.fromJson(Map.from(row)), + ), + ]; + } + + /// Geartype reference list: id -> display name. Accepts array-of-objects, + /// wrapped, or id->name map forms (shape unconfirmed, spec open question). + Future> getGeartypes() async { + final response = await _get('/geartypes'); + final decoded = _decode(response.body, '/geartypes'); + final result = {}; + if (decoded is Map && decoded.values.every((v) => v is String)) { + decoded.forEach((k, v) { + final id = int.tryParse('$k'); + if (id != null) result[id] = v as String; + }); + return result; + } + final rows = _rows(decoded, '/geartypes', const ['geartypes']); + for (final row in rows) { + if (row is Map) { + final id = row['id']; + final name = row['name']; + if (id is num && name is String) result[id.toInt()] = name; + } + } + return result; + } + + Future postGear(Map gear) async { + await _send('/gear', method: 'POST', jsonBody: gear); + } + + /// Certifications are created via multipart form-data (the endpoint also + /// accepts scan uploads, deferred to Phase 4). Same 401-retry-once + /// semantics as [_send]; the request is rebuilt for each attempt. + Future postCertification({ + required String name, + required String date, + String? org, + }) async { + var authRetried = false; + while (true) { + final token = await _getBearerToken(); + final request = http.MultipartRequest( + 'POST', + _baseUri.replace(path: '${_baseUri.path}/certifications'), + )..headers['Authorization'] = 'Bearer $token'; + request.fields['name'] = name; + request.fields['date'] = date; + if (org != null) request.fields['org'] = org; + final http.Response response; + try { + response = await http.Response.fromStream(await _http.send(request)); + } on Exception { + throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + _onTokenRejected(); + if (!authRetried) { + authRetried = true; + continue; + } + throw const DivelogsApiException( + 401, + 'divelogs.de sign-in expired. Sign in again in Settings.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsApiException( + response.statusCode, + 'divelogs.de API error ${response.statusCode}', + ); + } + return; + } + } + + List _rows(Object? decoded, String endpoint, List listKeys) { + if (decoded is List) return decoded; + if (decoded is Map) { + for (final key in listKeys) { + if (decoded[key] is List) return decoded[key] as List; + } + } + throw DivelogsApiException(0, 'Unexpected $endpoint response'); + } + + List? _maybe(T? value) => value == null ? null : [value]; + /// Decodes a response body, converting FormatException (non-JSON error /// pages, proxy-injected HTML) into the retryable DivelogsApiException the /// UI already handles. diff --git a/lib/core/services/divelogs/divelogs_models.dart b/lib/core/services/divelogs/divelogs_models.dart index 18c8e869b5..1c73dafea8 100644 --- a/lib/core/services/divelogs/divelogs_models.dart +++ b/lib/core/services/divelogs/divelogs_models.dart @@ -23,6 +23,11 @@ String? _asNonEmptyString(Object? v) { return trimmed.isEmpty ? null : trimmed; } +DateTime? _asUtcDate(Object? v) { + final s = _asNonEmptyString(v); + return s == null ? null : DateTime.tryParse('${s}T00:00:00Z'); +} + class DivelogsSample { final double depth; final double? temperature; @@ -88,6 +93,7 @@ class DivelogsDive { final double? surfaceTemp; final double? weightsKg; final int? surfaceIntervalSeconds; + final List gearItemIds; const DivelogsDive({ this.id, @@ -113,6 +119,7 @@ class DivelogsDive { this.surfaceTemp, this.weightsKg, this.surfaceIntervalSeconds, + this.gearItemIds = const [], }); factory DivelogsDive.fromJson(Map json) { @@ -185,6 +192,9 @@ class DivelogsDive { surfaceTemp: _asDouble(json['surfacetemp']), weightsKg: _asDouble(json['weights']), surfaceIntervalSeconds: _asInt(json['surface_interval']), + gearItemIds: json['gearitems'] is List + ? [for (final g in json['gearitems'] as List) '$g'] + : const [], ); } } @@ -244,3 +254,63 @@ class DivelogsDivelistResult { const DivelogsDivelistResult({required this.entries, this.skippedCount = 0}); } + +/// One row of GET /gear. Tolerant: unusable rows yield null. +class DivelogsGearItem { + final String id; + final String name; + final int? geartypeId; + final DateTime? purchaseDate; + final DateTime? lastServiceDate; + final DateTime? discardDate; + + const DivelogsGearItem({ + required this.id, + required this.name, + this.geartypeId, + this.purchaseDate, + this.lastServiceDate, + this.discardDate, + }); + + static DivelogsGearItem? fromJson(Map json) { + final rawId = json['id'] ?? json['gear_id']; + final name = _asNonEmptyString(json['name']); + if (rawId == null || name == null) return null; + return DivelogsGearItem( + id: '$rawId', + name: name, + geartypeId: _asInt(json['geartype']), + purchaseDate: _asUtcDate(json['purchasedate']), + lastServiceDate: _asUtcDate(json['last_servicedate']), + discardDate: _asUtcDate(json['discarddate']), + ); + } +} + +/// One row of GET /certifications. Tolerant: unusable rows yield null. +class DivelogsCertification { + final String? id; + final String name; + final DateTime? date; + final String? org; + + const DivelogsCertification({ + this.id, + required this.name, + this.date, + this.org, + }); + + static DivelogsCertification? fromJson(Map json) { + final name = _asNonEmptyString(json['name']); + if (name == null) return null; + final rawId = json['id']; + return DivelogsCertification( + id: rawId == null ? null : '$rawId', + name: name, + date: _asUtcDate(json['date']), + org: _asNonEmptyString(json['org']), + ); + } +} diff --git a/test/core/services/divelogs/divelogs_api_client_test.dart b/test/core/services/divelogs/divelogs_api_client_test.dart index de583c4524..37890e4534 100644 --- a/test/core/services/divelogs/divelogs_api_client_test.dart +++ b/test/core/services/divelogs/divelogs_api_client_test.dart @@ -179,4 +179,133 @@ void main() { ), ); }); + + test('getGear parses array and skips unusable rows', () async { + final api = client((req) async { + expect(req.url.path, '/api/gear'); + return http.Response( + jsonEncode([ + {'id': 45, 'name': 'Apex XTX50', 'geartype': 1}, + {'geartype': 2}, + ]), + 200, + ); + }); + final gear = await api.getGear(); + expect(gear, hasLength(1)); + expect(gear.single.id, '45'); + }); + + test('getGeartypes accepts array-of-objects form', () async { + final api = client( + (req) async => http.Response( + jsonEncode([ + {'id': 1, 'name': 'Regulator'}, + {'id': 2, 'name': 'Jacket'}, + ]), + 200, + ), + ); + expect(await api.getGeartypes(), {1: 'Regulator', 2: 'Jacket'}); + }); + + test('getGeartypes accepts id-to-name map form', () async { + final api = client( + (req) async => + http.Response(jsonEncode({'1': 'Regulator', '2': 'Jacket'}), 200), + ); + expect(await api.getGeartypes(), {1: 'Regulator', 2: 'Jacket'}); + }); + + test('getCertifications parses documented array', () async { + final api = client( + (req) async => http.Response( + jsonEncode([ + { + 'id': 123, + 'name': 'Open Water Diver', + 'date': '2022-06-15', + 'org': 'PADI', + }, + ]), + 200, + ), + ); + final certs = await api.getCertifications(); + expect(certs, hasLength(1)); + expect(certs.single.org, 'PADI'); + }); + + test('postGear sends JSON POST to /api/gear', () async { + late http.Request captured; + final api = client((req) async { + captured = req; + return http.Response('{}', 200); + }); + await api.postGear({'name': 'Apex XTX50', 'geartype': 1}); + expect(captured.method, 'POST'); + expect(captured.url.path, '/api/gear'); + expect(jsonDecode(captured.body), {'name': 'Apex XTX50', 'geartype': 1}); + }); + + test('postCertification sends multipart fields with bearer header', () async { + // MockClient materializes multipart bodies into encoded form-data, so + // capture the BaseRequest instead to assert on the typed fields. + late http.MultipartRequest captured; + var calls = 0; + final api = DivelogsApiClient( + getBearerToken: () async => 't1', + onTokenRejected: () {}, + httpClient: _CapturingClient((req) { + calls++; + captured = req as http.MultipartRequest; + }), + ); + await api.postCertification( + name: 'Open Water Diver', + date: '2022-06-15', + org: 'PADI', + ); + expect(calls, 1); + expect(captured.url.path, '/api/certifications'); + expect(captured.headers['Authorization'], 'Bearer t1'); + expect(captured.fields, { + 'name': 'Open Water Diver', + 'date': '2022-06-15', + 'org': 'PADI', + }); + }); + + test('postCertification retries once on 401', () async { + var calls = 0; + final tokens = ['t1', 't2']; + final api = DivelogsApiClient( + getBearerToken: () async => + tokens.length > 1 ? tokens.removeAt(0) : tokens.first, + onTokenRejected: () {}, + httpClient: _CapturingClient( + (req) => calls++, + statusFor: (req) => + req.headers['Authorization'] == 'Bearer t1' ? 401 : 200, + ), + ); + await api.postCertification(name: 'OWD', date: '2022-06-15'); + expect(calls, 2); + }); +} + +/// Minimal client that captures BaseRequests (MockClient materializes +/// multipart bodies, losing the fields we want to assert on). +class _CapturingClient extends http.BaseClient { + _CapturingClient(this.onRequest, {this.statusFor}); + + final void Function(http.BaseRequest) onRequest; + final int Function(http.BaseRequest)? statusFor; + + @override + Future send(http.BaseRequest request) async { + onRequest(request); + final status = statusFor?.call(request) ?? 200; + return http.StreamedResponse(Stream.value('{}'.codeUnits), status); + } } diff --git a/test/core/services/divelogs/divelogs_models_test.dart b/test/core/services/divelogs/divelogs_models_test.dart index 45f1f84b81..94264b47c9 100644 --- a/test/core/services/divelogs/divelogs_models_test.dart +++ b/test/core/services/divelogs/divelogs_models_test.dart @@ -137,4 +137,59 @@ void main() { expect(DivelogsDivelistEntry.fromJson({'id': 1}), isNull); }); }); + + group('DivelogsGearItem', () { + test('parses fields with wall-clock UTC dates', () { + final gear = DivelogsGearItem.fromJson({ + 'id': 45, + 'name': 'Apex XTX50', + 'geartype': 1, + 'purchasedate': '2007-05-12', + 'last_servicedate': '2024-01-02', + 'discarddate': null, + })!; + expect(gear.id, '45'); + expect(gear.name, 'Apex XTX50'); + expect(gear.geartypeId, 1); + expect(gear.purchaseDate, DateTime.utc(2007, 5, 12)); + expect(gear.lastServiceDate, DateTime.utc(2024, 1, 2)); + expect(gear.discardDate, isNull); + }); + + test('returns null without id or name', () { + expect(DivelogsGearItem.fromJson({'name': 'X'}), isNull); + expect(DivelogsGearItem.fromJson({'id': 1}), isNull); + }); + }); + + group('DivelogsCertification', () { + test('parses fields', () { + final cert = DivelogsCertification.fromJson({ + 'id': 123, + 'name': 'Open Water Diver', + 'date': '2022-06-15', + 'org': 'PADI', + })!; + expect(cert.id, '123'); + expect(cert.name, 'Open Water Diver'); + expect(cert.date, DateTime.utc(2022, 6, 15)); + expect(cert.org, 'PADI'); + }); + + test('returns null without a name', () { + expect(DivelogsCertification.fromJson({'id': 1}), isNull); + }); + }); + + test('DivelogsDive parses gearitems as string ids', () { + final dive = DivelogsDive.fromJson({ + 'id': 1, + 'date': '2022-09-03', + 'time': '10:00:00', + 'duration': 60, + 'maxdepth': 5, + 'gearitems': [45, 62], + }); + expect(dive.gearItemIds, ['45', '62']); + }); } From 1be53a82904848c0534b9f396a4565d2bb63bc42 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:47:11 -0400 Subject: [PATCH 23/35] feat: map divelogs.de geartypes and orgs onto domain enums --- .../mappers/divelogs_reference_mappers.dart | 87 +++++++++++++ .../divelogs_reference_mappers_test.dart | 120 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 lib/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart create mode 100644 test/features/divelogs_sync/data/mappers/divelogs_reference_mappers_test.dart diff --git a/lib/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart b/lib/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart new file mode 100644 index 0000000000..47ac02e2e0 --- /dev/null +++ b/lib/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart @@ -0,0 +1,87 @@ +import 'package:submersion/core/constants/enums.dart'; + +/// Pure lookup tables between divelogs.de reference data (geartype names, +/// certification orgs) and Submersion's domain enums. Geartype names carry +/// German synonyms — divelogs.de's home locale — alongside English. +abstract final class DivelogsReferenceMappers { + /// Keyword table, first match wins. Drysuit keywords come before the + /// generic suit keywords so "Trockentauchanzug"/"Drysuit" do not fall + /// into wetsuit. + static const List<(List, EquipmentType)> _geartypeKeywords = [ + (['regulator', 'lungenautomat', 'atemregler'], EquipmentType.regulator), + (['bcd', 'jacket', 'wing', 'tarierweste'], EquipmentType.bcd), + (['drysuit', 'dry suit', 'trocken'], EquipmentType.drysuit), + (['wetsuit', 'wet suit', 'nass', 'suit', 'anzug'], EquipmentType.wetsuit), + (['fin', 'flosse'], EquipmentType.fins), + (['mask', 'maske'], EquipmentType.mask), + (['computer'], EquipmentType.computer), + (['tank', 'cylinder', 'flasche'], EquipmentType.tank), + (['weight', 'blei'], EquipmentType.weights), + (['light', 'lamp', 'lampe'], EquipmentType.light), + (['camera', 'kamera'], EquipmentType.camera), + (['boot', 'füßling', 'fussling'], EquipmentType.boots), + (['glove', 'handschuh'], EquipmentType.gloves), + (['hood', 'haube'], EquipmentType.hood), + (['knife', 'messer'], EquipmentType.knife), + (['reel'], EquipmentType.reel), + (['smb', 'boje'], EquipmentType.smb), + ]; + + static EquipmentType equipmentTypeForGeartypeName(String? name) { + if (name == null) return EquipmentType.other; + final lower = name.trim().toLowerCase(); + if (lower.isEmpty) return EquipmentType.other; + for (final (keywords, type) in _geartypeKeywords) { + if (keywords.any(lower.contains)) return type; + } + return EquipmentType.other; + } + + /// First remote geartype id whose name maps to [type], or null. + static int? geartypeIdForEquipmentType( + EquipmentType type, + Map geartypes, + ) { + for (final entry in geartypes.entries) { + if (equipmentTypeForGeartypeName(entry.value) == type) { + return entry.key; + } + } + return null; + } + + /// First remote geartype name that maps to [type], or null. + static String? geartypeNameForEquipmentType( + EquipmentType type, + Map geartypes, + ) { + final id = geartypeIdForEquipmentType(type, geartypes); + return id == null ? null : geartypes[id]; + } + + static CertificationAgency agencyForOrg(String? org) { + if (org == null) return CertificationAgency.other; + final lower = org.trim().toLowerCase(); + if (lower.isEmpty) return CertificationAgency.other; + for (final agency in CertificationAgency.values) { + if (agency.name.toLowerCase() == lower || + agency.displayName.toLowerCase() == lower) { + return agency; + } + } + return CertificationAgency.other; + } + + /// Matches a remote certification name onto a known level, or null. + /// The original text stays in the certification's name field either way. + static CertificationLevel? levelForName(String name) { + final lower = name.trim().toLowerCase(); + for (final level in CertificationLevel.values) { + if (level != CertificationLevel.other && + level.displayName.toLowerCase() == lower) { + return level; + } + } + return null; + } +} diff --git a/test/features/divelogs_sync/data/mappers/divelogs_reference_mappers_test.dart b/test/features/divelogs_sync/data/mappers/divelogs_reference_mappers_test.dart new file mode 100644 index 0000000000..2a05407ef4 --- /dev/null +++ b/test/features/divelogs_sync/data/mappers/divelogs_reference_mappers_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart'; + +void main() { + group('equipmentTypeForGeartypeName', () { + test('maps English and German geartype names', () { + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName('Regulator'), + EquipmentType.regulator, + ); + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName('Atemregler'), + EquipmentType.regulator, + ); + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName('Jacket'), + EquipmentType.bcd, + ); + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName('Flossen'), + EquipmentType.fins, + ); + }); + + test('drysuit wins over wetsuit for suit names', () { + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName( + 'Trockentauchanzug', + ), + EquipmentType.drysuit, + ); + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName('Nassanzug'), + EquipmentType.wetsuit, + ); + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName('Drysuit'), + EquipmentType.drysuit, + ); + }); + + test('unknown or null names map to other', () { + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName('Gadget'), + EquipmentType.other, + ); + expect( + DivelogsReferenceMappers.equipmentTypeForGeartypeName(null), + EquipmentType.other, + ); + }); + }); + + group('geartypeIdForEquipmentType', () { + test('finds the first remote geartype mapping to the type', () { + expect( + DivelogsReferenceMappers.geartypeIdForEquipmentType(EquipmentType.bcd, { + 1: 'Regulator', + 2: 'Jacket', + }), + 2, + ); + }); + + test('returns null when nothing maps', () { + expect( + DivelogsReferenceMappers.geartypeIdForEquipmentType( + EquipmentType.camera, + {1: 'Regulator'}, + ), + isNull, + ); + }); + }); + + group('agencyForOrg', () { + test('matches enum names case-insensitively', () { + expect( + DivelogsReferenceMappers.agencyForOrg('PADI'), + CertificationAgency.padi, + ); + expect( + DivelogsReferenceMappers.agencyForOrg('ssi '), + CertificationAgency.ssi, + ); + }); + + test('unknown or null orgs map to other', () { + expect( + DivelogsReferenceMappers.agencyForOrg('Some Club'), + CertificationAgency.other, + ); + expect( + DivelogsReferenceMappers.agencyForOrg(null), + CertificationAgency.other, + ); + }); + }); + + group('levelForName', () { + test('matches display names case-insensitively', () { + expect( + DivelogsReferenceMappers.levelForName('Open Water'), + CertificationLevel.openWater, + ); + expect( + DivelogsReferenceMappers.levelForName('open water'), + CertificationLevel.openWater, + ); + }); + + test('unrecognized names return null', () { + expect( + DivelogsReferenceMappers.levelForName('Fancy Specialty XYZ'), + isNull, + ); + }); + }); +} From 2ee50e2fdf1e79c95ddaf60339920fd48484e22e Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:49:28 -0400 Subject: [PATCH 24/35] feat: pull divelogs.de gear and certifications through the import wizard --- .../data/services/divelogs_dive_mapper.dart | 7 ++ .../services/divelogs_import_service.dart | 74 +++++++++++ .../widgets/divelogs_fetch_step_test.dart | 5 +- .../services/divelogs_dive_mapper_test.dart | 22 ++++ .../divelogs_import_service_test.dart | 119 +++++++++++++++++- 5 files changed, 222 insertions(+), 5 deletions(-) diff --git a/lib/features/universal_import/data/services/divelogs_dive_mapper.dart b/lib/features/universal_import/data/services/divelogs_dive_mapper.dart index 99b48cd482..c42c95e63f 100644 --- a/lib/features/universal_import/data/services/divelogs_dive_mapper.dart +++ b/lib/features/universal_import/data/services/divelogs_dive_mapper.dart @@ -12,6 +12,10 @@ class DivelogsDiveMapper { static String siteKey(String name) => 'divelogs-site-${name.trim().toLowerCase()}'; + /// Payload ref key for a remote gear item; must match the `uddfId` used + /// by the equipment entities so `equipmentIdMapping` links dives to gear. + static String gearKey(String id) => 'divelogs-gear-$id'; + Map mapDive(DivelogsDive dive) { final map = { 'dateTime': dive.dateTime, @@ -43,6 +47,9 @@ class DivelogsDiveMapper { map['surfaceInterval'] = Duration(seconds: dive.surfaceIntervalSeconds!); } if (dive.id != null) map['sourceUuid'] = 'divelogs:${dive.id}'; + if (dive.gearItemIds.isNotEmpty) { + map['equipmentRefs'] = [for (final id in dive.gearItemIds) gearKey(id)]; + } final siteName = dive.siteName; if (siteName != null) { diff --git a/lib/features/universal_import/data/services/divelogs_import_service.dart b/lib/features/universal_import/data/services/divelogs_import_service.dart index de4b9c0b93..5249a114e8 100644 --- a/lib/features/universal_import/data/services/divelogs_import_service.dart +++ b/lib/features/universal_import/data/services/divelogs_import_service.dart @@ -1,4 +1,7 @@ +import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; import 'package:submersion/features/universal_import/data/models/import_warning.dart'; @@ -19,6 +22,40 @@ class DivelogsImportService { Future fetchAllDives() async { final result = await _api.getAllDives(); + // Gear/geartypes/certifications degrade independently: a failure on any + // of them becomes a warning and must never abort the dive pull. + final extraWarnings = []; + Map geartypes = const {}; + var gear = const []; + var certs = const []; + try { + geartypes = await _api.getGeartypes(); + } on DivelogsApiException { + // Types degrade to EquipmentType.other; not worth a user warning. + } + try { + gear = await _api.getGear(); + } on DivelogsApiException catch (e) { + extraWarnings.add( + ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'Gear could not be fetched from divelogs.de: ${e.message}', + ), + ); + } + try { + certs = await _api.getCertifications(); + } on DivelogsApiException catch (e) { + extraWarnings.add( + ImportWarning( + severity: ImportWarningSeverity.warning, + message: + 'Certifications could not be fetched from divelogs.de: ' + '${e.message}', + ), + ); + } + final diveEntities = >[]; final sitesByKey = >{}; for (final dive in result.dives) { @@ -38,6 +75,36 @@ class DivelogsImportService { } } + final equipmentEntities = [ + for (final item in gear) + { + 'uddfId': DivelogsDiveMapper.gearKey(item.id), + 'name': item.name, + 'type': DivelogsReferenceMappers.equipmentTypeForGeartypeName( + geartypes[item.geartypeId], + ), + if (item.purchaseDate != null) 'purchaseDate': item.purchaseDate, + if (item.lastServiceDate != null) + 'lastServiceDate': item.lastServiceDate, + 'status': item.discardDate != null + ? EquipmentStatus.retired + : EquipmentStatus.active, + 'isActive': item.discardDate == null, + }, + ]; + + final certEntities = [ + for (final cert in certs) + { + 'uddfId': 'divelogs-cert-${cert.id ?? cert.name}', + 'name': cert.name, + 'agency': DivelogsReferenceMappers.agencyForOrg(cert.org), + if (cert.date != null) 'issueDate': cert.date, + if (DivelogsReferenceMappers.levelForName(cert.name) != null) + 'level': DivelogsReferenceMappers.levelForName(cert.name), + }, + ]; + final entities = >>{}; if (diveEntities.isNotEmpty) { entities[ImportEntityType.dives] = diveEntities; @@ -45,6 +112,12 @@ class DivelogsImportService { if (sitesByKey.isNotEmpty) { entities[ImportEntityType.sites] = sitesByKey.values.toList(); } + if (equipmentEntities.isNotEmpty) { + entities[ImportEntityType.equipment] = equipmentEntities; + } + if (certEntities.isNotEmpty) { + entities[ImportEntityType.certifications] = certEntities; + } return ImportPayload( entities: entities, @@ -57,6 +130,7 @@ class DivelogsImportService { : '${result.skippedCount} dives could not be read from ' 'divelogs.de and were skipped.', ), + ...extraWarnings, ], metadata: {'source': 'divelogs.de', 'diveCount': result.dives.length}, ); diff --git a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart index b029e23f0f..e08960a09f 100644 --- a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart @@ -66,7 +66,10 @@ void main() { loginStatus, ); } - if (req.url.path == '/api/dives') { + if (req.url.path == '/api/dives' || + req.url.path == '/api/gear' || + req.url.path == '/api/geartypes' || + req.url.path == '/api/certifications') { return http.Response(jsonEncode([]), 200); } fail('unexpected request ${req.url}'); diff --git a/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart b/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart index fcb3222524..edd5dedc5f 100644 --- a/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart +++ b/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart @@ -112,6 +112,28 @@ void main() { expect(mapper.mapDive(d).containsKey('site'), isFalse); }); + test('maps gearitems to equipmentRefs with the divelogs gear keys', () { + final d = DivelogsDive( + dateTime: DateTime.utc(2022), + durationSeconds: 60, + maxDepth: 5, + gearItemIds: const ['45', '62'], + ); + expect(mapper.mapDive(d)['equipmentRefs'], [ + 'divelogs-gear-45', + 'divelogs-gear-62', + ]); + }); + + test('no equipmentRefs key without gearitems', () { + final d = DivelogsDive( + dateTime: DateTime.utc(2022), + durationSeconds: 60, + maxDepth: 5, + ); + expect(mapper.mapDive(d).containsKey('equipmentRefs'), isFalse); + }); + test('zero weights and temps are treated as unset', () { final d = DivelogsDive( dateTime: DateTime.utc(2022), diff --git a/test/features/universal_import/data/services/divelogs_import_service_test.dart b/test/features/universal_import/data/services/divelogs_import_service_test.dart index 26724152f9..66144e5c59 100644 --- a/test/features/universal_import/data/services/divelogs_import_service_test.dart +++ b/test/features/universal_import/data/services/divelogs_import_service_test.dart @@ -3,6 +3,7 @@ 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/services/divelogs/divelogs_api_client.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; @@ -26,13 +27,30 @@ void main() { 'lng': 35.1, }; - DivelogsImportService service(Object body) => DivelogsImportService( + DivelogsImportService service( + Object dives, { + Object gear = const [], + Object geartypes = const [], + Object certifications = const [], + int gearStatus = 200, + int certStatus = 200, + }) => DivelogsImportService( api: DivelogsApiClient( getBearerToken: () async => 't', onTokenRejected: () {}, - httpClient: MockClient( - (req) async => http.Response(jsonEncode(body), 200), - ), + httpClient: MockClient((req) async { + switch (req.url.path) { + case '/api/dives': + return http.Response(jsonEncode(dives), 200); + case '/api/gear': + return http.Response(jsonEncode(gear), gearStatus); + case '/api/geartypes': + return http.Response(jsonEncode(geartypes), 200); + case '/api/certifications': + return http.Response(jsonEncode(certifications), certStatus); + } + fail('unexpected request ${req.url}'); + }), ), ); @@ -68,6 +86,99 @@ void main() { ); }); + group('gear and certification pull', () { + test('maps gear rows into equipment entities', () async { + final payload = await service( + [diveJson(1)], + gear: [ + {'id': 45, 'name': 'Apex XTX50', 'geartype': 1}, + { + 'id': 46, + 'name': 'Old BCD', + 'geartype': 2, + 'discarddate': '2020-01-01', + }, + ], + geartypes: [ + {'id': 1, 'name': 'Regulator'}, + {'id': 2, 'name': 'Jacket'}, + ], + ).fetchAllDives(); + + final equipment = payload.entitiesOf(ImportEntityType.equipment); + expect(equipment, hasLength(2)); + expect(equipment[0]['uddfId'], 'divelogs-gear-45'); + expect(equipment[0]['type'], EquipmentType.regulator); + expect(equipment[0]['status'], EquipmentStatus.active); + expect(equipment[1]['status'], EquipmentStatus.retired); + expect(equipment[1]['isActive'], isFalse); + }); + + test('maps certification rows into certification entities', () async { + final payload = await service( + [diveJson(1)], + certifications: [ + { + 'id': 123, + 'name': 'Open Water', + 'date': '2022-06-15', + 'org': 'PADI', + }, + ], + ).fetchAllDives(); + + final certs = payload.entitiesOf(ImportEntityType.certifications); + expect(certs, hasLength(1)); + expect(certs.single['agency'], CertificationAgency.padi); + expect(certs.single['issueDate'], DateTime.utc(2022, 6, 15)); + expect(certs.single['level'], CertificationLevel.openWater); + }); + + test('dive gearitems become equipmentRefs', () async { + final payload = await service( + [ + { + ...diveJson(1), + 'gearitems': [45], + }, + ], + gear: [ + {'id': 45, 'name': 'Apex XTX50'}, + ], + ).fetchAllDives(); + + expect( + payload.entitiesOf(ImportEntityType.dives).single['equipmentRefs'], + ['divelogs-gear-45'], + ); + }); + + test('gear fetch failure degrades to a warning, dives survive', () async { + final payload = await service([ + diveJson(1), + ], gearStatus: 500).fetchAllDives(); + + expect(payload.entitiesOf(ImportEntityType.dives), hasLength(1)); + expect(payload.entitiesOf(ImportEntityType.equipment), isEmpty); + expect( + payload.warnings.map((w) => w.message), + anyElement(contains('Gear')), + ); + }); + + test('certification fetch failure degrades to a warning', () async { + final payload = await service([ + diveJson(1), + ], certStatus: 500).fetchAllDives(); + + expect(payload.entitiesOf(ImportEntityType.dives), hasLength(1)); + expect( + payload.warnings.map((w) => w.message), + anyElement(contains('Certifications')), + ); + }); + }); + group('duplicate checker integration', () { final existingDive = Dive( id: 'existing-1', From 1e82158a6bfdb1ab6181b7bba228a1e8e2d90911 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:50:28 -0400 Subject: [PATCH 25/35] feat: add create-only gear and certification sync planner --- .../services/gear_cert_sync_planner.dart | 115 +++++++++++++++++ .../services/gear_cert_sync_planner_test.dart | 121 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 lib/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart create mode 100644 test/features/divelogs_sync/domain/services/gear_cert_sync_planner_test.dart diff --git a/lib/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart b/lib/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart new file mode 100644 index 0000000000..f53f8d50e8 --- /dev/null +++ b/lib/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart @@ -0,0 +1,115 @@ +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; + +/// Push/pull diff for gear and certifications (create-only, spec Phase 3). +/// Pull counts are informational — pulling happens in the import wizard. +class GearCertSyncPlan { + final List pushGear; + final List pushCerts; + final int matchedGear; + final int matchedCerts; + final int pullGear; + final int pullCerts; + + /// Local certs excluded from push because the API requires a date. + final int certsMissingDate; + + const GearCertSyncPlan({ + required this.pushGear, + required this.pushCerts, + required this.matchedGear, + required this.matchedCerts, + required this.pullGear, + required this.pullCerts, + required this.certsMissingDate, + }); + + bool get hasPush => pushGear.isNotEmpty || pushCerts.isNotEmpty; +} + +/// Name-keyed create-only matching: gear by normalized name; certifications +/// by normalized name plus calendar date when both sides carry one. +class GearCertSyncPlanner { + const GearCertSyncPlanner(); + + GearCertSyncPlan plan({ + required List remoteGear, + required List remoteCerts, + required List localGear, + required List localCerts, + }) { + String norm(String s) => s.trim().toLowerCase(); + + // Gear: one-to-one consumption of remote names. + final remoteGearNames = {}; + for (final item in remoteGear) { + remoteGearNames.update(norm(item.name), (c) => c + 1, ifAbsent: () => 1); + } + final unmatchedLocalGear = []; + var matchedGear = 0; + for (final item in localGear) { + final key = norm(item.name); + final remaining = remoteGearNames[key] ?? 0; + if (remaining > 0) { + remoteGearNames[key] = remaining - 1; + matchedGear++; + } else { + unmatchedLocalGear.add(item); + } + } + final pullGear = remoteGearNames.values.fold(0, (sum, c) => sum + c); + final pushGear = [ + for (final item in unmatchedLocalGear) + if (item.isActive && + item.status != EquipmentStatus.retired && + item.status != EquipmentStatus.lost) + item, + ]; + + // Certifications: key by name, refine by calendar date when both known. + String certKey(String name, DateTime? date) => date == null + ? norm(name) + : '${norm(name)}|${date.year}-${date.month}-${date.day}'; + bool matches(DivelogsCertification remote, Certification local) { + if (norm(remote.name) != norm(local.name)) return false; + final rd = remote.date; + final ld = local.issueDate; + if (rd == null || ld == null) return true; + return certKey(remote.name, rd) == certKey(local.name, ld); + } + + final unmatchedRemoteCerts = [...remoteCerts]; + final pushCertCandidates = []; + var matchedCerts = 0; + for (final local in localCerts) { + final index = unmatchedRemoteCerts.indexWhere( + (remote) => matches(remote, local), + ); + if (index >= 0) { + unmatchedRemoteCerts.removeAt(index); + matchedCerts++; + } else { + pushCertCandidates.add(local); + } + } + final certsMissingDate = pushCertCandidates + .where((c) => c.issueDate == null) + .length; + final pushCerts = [ + for (final cert in pushCertCandidates) + if (cert.issueDate != null) cert, + ]; + + return GearCertSyncPlan( + pushGear: pushGear, + pushCerts: pushCerts, + matchedGear: matchedGear, + matchedCerts: matchedCerts, + pullGear: pullGear, + pullCerts: unmatchedRemoteCerts.length, + certsMissingDate: certsMissingDate, + ); + } +} diff --git a/test/features/divelogs_sync/domain/services/gear_cert_sync_planner_test.dart b/test/features/divelogs_sync/domain/services/gear_cert_sync_planner_test.dart new file mode 100644 index 0000000000..b146afe260 --- /dev/null +++ b/test/features/divelogs_sync/domain/services/gear_cert_sync_planner_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; + +void main() { + const planner = GearCertSyncPlanner(); + final now = DateTime.utc(2024); + + EquipmentItem gear( + String id, + String name, { + EquipmentStatus status = EquipmentStatus.active, + bool isActive = true, + }) => EquipmentItem( + id: id, + name: name, + type: EquipmentType.regulator, + status: status, + isActive: isActive, + ); + + Certification cert(String id, String name, {DateTime? issueDate}) => + Certification( + id: id, + name: name, + agency: CertificationAgency.padi, + issueDate: issueDate, + createdAt: now, + updatedAt: now, + ); + + GearCertSyncPlan plan({ + List remoteGear = const [], + List remoteCerts = const [], + List localGear = const [], + List localCerts = const [], + }) => planner.plan( + remoteGear: remoteGear, + remoteCerts: remoteCerts, + localGear: localGear, + localCerts: localCerts, + ); + + test('gear matches by case-insensitive name', () { + final result = plan( + remoteGear: const [DivelogsGearItem(id: '1', name: 'apex xtx50')], + localGear: [gear('l1', 'Apex XTX50')], + ); + expect(result.matchedGear, 1); + expect(result.pushGear, isEmpty); + expect(result.pullGear, 0); + }); + + test('local-only active gear is pushed, remote-only counted as pull', () { + final result = plan( + remoteGear: const [DivelogsGearItem(id: '1', name: 'Remote Only')], + localGear: [gear('l1', 'Local Only')], + ); + expect(result.pushGear.map((g) => g.id), ['l1']); + expect(result.pullGear, 1); + }); + + test('retired local gear matches but is never pushed', () { + final result = plan( + localGear: [ + gear('l1', 'Old Reg', status: EquipmentStatus.retired, isActive: false), + ], + ); + expect(result.pushGear, isEmpty); + }); + + test('certs match by name plus calendar date when both present', () { + final result = plan( + remoteCerts: [ + DivelogsCertification( + name: 'Open Water', + date: DateTime.utc(2022, 6, 15), + ), + ], + localCerts: [ + cert('c1', 'open water', issueDate: DateTime.utc(2022, 6, 15)), + ], + ); + expect(result.matchedCerts, 1); + expect(result.pushCerts, isEmpty); + expect(result.pullCerts, 0); + }); + + test('same cert name with different dates is both push and pull', () { + final result = plan( + remoteCerts: [ + DivelogsCertification( + name: 'Open Water', + date: DateTime.utc(2020, 1, 1), + ), + ], + localCerts: [ + cert('c1', 'Open Water', issueDate: DateTime.utc(2022, 6, 15)), + ], + ); + expect(result.pushCerts, hasLength(1)); + expect(result.pullCerts, 1); + }); + + test('cert without a date on one side matches by name alone', () { + final result = plan( + remoteCerts: const [DivelogsCertification(name: 'Open Water')], + localCerts: [cert('c1', 'Open Water', issueDate: DateTime.utc(2022))], + ); + expect(result.matchedCerts, 1); + }); + + test('local certs without issueDate are excluded from push and counted', () { + final result = plan(localCerts: [cert('c1', 'Nitrox')]); + expect(result.pushCerts, isEmpty); + expect(result.certsMissingDate, 1); + }); +} From 6d978d86d87a6781244f6c6d5c36330a0e600e70 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:53:06 -0400 Subject: [PATCH 26/35] feat: push gear and certifications to divelogs.de with dive gear links --- .../data/mappers/divelogs_export_mapper.dart | 25 +++- .../divelogs_gear_cert_push_service.dart | 75 ++++++++++ .../services/divelogs_push_service.dart | 3 +- .../mappers/divelogs_export_mapper_test.dart | 32 +++++ .../divelogs_gear_cert_push_service_test.dart | 129 ++++++++++++++++++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart create mode 100644 test/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service_test.dart diff --git a/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart b/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart index 95eba803e1..0611717366 100644 --- a/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart +++ b/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart @@ -1,5 +1,11 @@ import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +/// Formats a wall-clock UTC date the way the divelogs.de API expects. +String divelogsDate(DateTime d) { + String two(int v) => v.toString().padLeft(2, '0'); + return '${d.year}-${two(d.month)}-${two(d.day)}'; +} + /// Projects a domain Dive onto the divelogs.de dive JSON schema. /// /// Lossy by design (spec: push path): one profile channel, tanks, site @@ -9,7 +15,10 @@ import 'package:submersion/features/dive_log/domain/entities/dive.dart'; class DivelogsExportMapper { const DivelogsExportMapper(); - Map? mapDive(Dive dive) { + Map? mapDive( + Dive dive, { + Map remoteGearIdByName = const {}, + }) { final entry = dive.effectiveEntryTime; final durationSeconds = dive.effectiveRuntime?.inSeconds; final maxDepth = dive.maxDepth ?? dive.calculateMaxDepthFromProfile(); @@ -19,12 +28,19 @@ class DivelogsExportMapper { String two(int v) => v.toString().padLeft(2, '0'); final json = { - 'date': '${entry.year}-${two(entry.month)}-${two(entry.day)}', + 'date': divelogsDate(entry), 'time': '${two(entry.hour)}:${two(entry.minute)}:${two(entry.second)}', 'duration': durationSeconds, 'maxdepth': maxDepth, }; + final gearIds = [ + for (final item in dive.equipment) + if (remoteGearIdByName[item.name.trim().toLowerCase()] != null) + ...?_parsedId(remoteGearIdByName[item.name.trim().toLowerCase()]!), + ]; + if (gearIds.isNotEmpty) json['gearitems'] = gearIds; + final avg = dive.avgDepth; if (avg != null && avg > 0) json['meandepth'] = avg; if (dive.buddy != null) json['buddy'] = dive.buddy; @@ -73,6 +89,11 @@ class DivelogsExportMapper { return json; } + List? _parsedId(String raw) { + final id = int.tryParse(raw); + return id == null ? null : [id]; + } + /// divelogs sampledata assumes one fixed sample rate, so only uniform /// profiles are exported; anything else is omitted rather than distorted. void _addProfile(Map json, List profile) { diff --git a/lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart b/lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart new file mode 100644 index 0000000000..2a016f6bde --- /dev/null +++ b/lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart @@ -0,0 +1,75 @@ +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart'; +import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_reference_mappers.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; + +class GearCertPushResult { + final int gearPushed; + final int certsPushed; + final String? error; + + const GearCertPushResult({ + required this.gearPushed, + required this.certsPushed, + this.error, + }); + + bool get failed => error != null; +} + +/// Create-only push of gear and certifications (spec Phase 3). Sequential; +/// a failure stops the run and reports partial counts — the next compare +/// converges on whatever was already created (stateless model). +class DivelogsGearCertPushService { + DivelogsGearCertPushService({required DivelogsApiClient api}) : _api = api; + + final DivelogsApiClient _api; + + Future push({ + required List gear, + required List certs, + required Map geartypes, + }) async { + var gearPushed = 0; + var certsPushed = 0; + try { + for (final item in gear) { + final geartypeId = DivelogsReferenceMappers.geartypeIdForEquipmentType( + item.type, + geartypes, + ); + final purchaseDate = item.purchaseDate; + final lastServiceDate = item.lastServiceDate; + await _api.postGear({ + 'name': item.name, + ?'geartype': geartypeId, + if (purchaseDate != null) 'purchasedate': divelogsDate(purchaseDate), + if (lastServiceDate != null) + 'last_servicedate': divelogsDate(lastServiceDate), + }); + gearPushed++; + } + for (final cert in certs) { + final issueDate = cert.issueDate; + if (issueDate == null) continue; // planner excludes these already + await _api.postCertification( + name: cert.name, + date: divelogsDate(issueDate), + org: cert.agency == CertificationAgency.other + ? null + : cert.agency.displayName, + ); + certsPushed++; + } + } on DivelogsApiException catch (e) { + return GearCertPushResult( + gearPushed: gearPushed, + certsPushed: certsPushed, + error: e.message, + ); + } + return GearCertPushResult(gearPushed: gearPushed, certsPushed: certsPushed); + } +} diff --git a/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart b/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart index f55825d401..d19eea1880 100644 --- a/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart +++ b/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart @@ -38,11 +38,12 @@ class DivelogsPushService { Future push( List dives, { void Function(int done, int total)? onProgress, + Map remoteGearIdByName = const {}, }) async { final mapped = >[]; var skipped = 0; for (final dive in dives) { - final json = mapper.mapDive(dive); + final json = mapper.mapDive(dive, remoteGearIdByName: remoteGearIdByName); if (json == null) { skipped++; } else { diff --git a/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart b/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart index 5eadf631de..17ce71c9b1 100644 --- a/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart +++ b/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; import 'package:submersion/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart'; @@ -109,6 +111,36 @@ void main() { expect(mapper.mapDive(dive(maxDepth: null)), isNull); }); + test('emits gearitems for equipment with known remote ids', () { + final withGear = Dive( + id: 'd2', + dateTime: DateTime.utc(2022, 9, 3, 14), + entryTime: DateTime.utc(2022, 9, 3, 14), + runtime: const Duration(minutes: 40), + maxDepth: 10, + equipment: const [ + EquipmentItem( + id: 'e1', + name: 'Apex XTX50', + type: EquipmentType.regulator, + ), + EquipmentItem( + id: 'e2', + name: 'Unknown Remote', + type: EquipmentType.other, + ), + ], + ); + final json = mapper.mapDive( + withGear, + remoteGearIdByName: {'apex xtx50': '45'}, + )!; + expect(json['gearitems'], [45]); + + final without = mapper.mapDive(withGear)!; + expect(without.containsKey('gearitems'), isFalse); + }); + test('falls back to profile max depth when maxDepth is null', () { final json = mapper.mapDive( dive( diff --git a/test/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service_test.dart b/test/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service_test.dart new file mode 100644 index 0000000000..df01e06e57 --- /dev/null +++ b/test/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service_test.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; + +/// Captures BaseRequests so multipart cert fields stay inspectable. +class _CapturingClient extends http.BaseClient { + _CapturingClient(this.onRequest, {this.statusFor}); + + final void Function(http.BaseRequest) onRequest; + final int Function(http.BaseRequest)? statusFor; + + @override + Future send(http.BaseRequest request) async { + onRequest(request); + final status = statusFor?.call(request) ?? 200; + return http.StreamedResponse(Stream.value('{}'.codeUnits), status); + } +} + +void main() { + final now = DateTime.utc(2024); + + DivelogsApiClient api( + void Function(http.BaseRequest) onRequest, { + int Function(http.BaseRequest)? statusFor, + }) => DivelogsApiClient( + getBearerToken: () async => 't', + onTokenRejected: () {}, + httpClient: _CapturingClient(onRequest, statusFor: statusFor), + ); + + test( + 'pushes gear with mapped geartype and formatted dates, then certs', + () async { + final requests = []; + final service = DivelogsGearCertPushService(api: api(requests.add)); + final result = await service.push( + gear: [ + EquipmentItem( + id: 'g1', + name: 'Zeagle Ranger', + type: EquipmentType.bcd, + purchaseDate: DateTime.utc(2020, 3, 5), + ), + ], + certs: [ + Certification( + id: 'c1', + name: 'Open Water', + agency: CertificationAgency.padi, + issueDate: DateTime.utc(2022, 6, 15), + createdAt: now, + updatedAt: now, + ), + ], + geartypes: const {1: 'Regulator', 2: 'Jacket'}, + ); + + expect(result.gearPushed, 1); + expect(result.certsPushed, 1); + expect(result.failed, isFalse); + + final gearReq = requests[0] as http.Request; + expect(gearReq.url.path, '/api/gear'); + expect(jsonDecode(gearReq.body), { + 'name': 'Zeagle Ranger', + 'geartype': 2, + 'purchasedate': '2020-03-05', + }); + + final certReq = requests[1] as http.MultipartRequest; + expect(certReq.url.path, '/api/certifications'); + expect(certReq.fields, { + 'name': 'Open Water', + 'date': '2022-06-15', + 'org': 'PADI', + }); + }, + ); + + test('other-agency certs omit org; unmappable geartype omitted', () async { + final requests = []; + await DivelogsGearCertPushService(api: api(requests.add)).push( + gear: [ + const EquipmentItem(id: 'g1', name: 'Cam', type: EquipmentType.camera), + ], + certs: [ + Certification( + id: 'c1', + name: 'Club Cert', + agency: CertificationAgency.other, + issueDate: DateTime.utc(2021, 2, 3), + createdAt: now, + updatedAt: now, + ), + ], + geartypes: const {1: 'Regulator'}, + ); + + final gearReq = requests[0] as http.Request; + expect(jsonDecode(gearReq.body), {'name': 'Cam'}); + final certReq = requests[1] as http.MultipartRequest; + expect(certReq.fields.containsKey('org'), isFalse); + }); + + test('a failure stops the run and reports partial counts', () async { + var calls = 0; + final service = DivelogsGearCertPushService( + api: api((_) => calls++, statusFor: (req) => calls <= 1 ? 200 : 500), + ); + final result = await service.push( + gear: [ + const EquipmentItem(id: 'g1', name: 'A', type: EquipmentType.other), + const EquipmentItem(id: 'g2', name: 'B', type: EquipmentType.other), + ], + certs: const [], + geartypes: const {}, + ); + expect(result.gearPushed, 1); + expect(result.failed, isTrue); + expect(result.error, contains('500')); + }); +} From 20715d250a009d4ef169eea33dd3b18c5aefb61f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:54:07 -0400 Subject: [PATCH 27/35] fix: null-aware geartype value in gear push body --- .../domain/services/divelogs_gear_cert_push_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart b/lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart index 2a016f6bde..812f6d1c65 100644 --- a/lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart +++ b/lib/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart @@ -44,7 +44,7 @@ class DivelogsGearCertPushService { final lastServiceDate = item.lastServiceDate; await _api.postGear({ 'name': item.name, - ?'geartype': geartypeId, + 'geartype': ?geartypeId, if (purchaseDate != null) 'purchasedate': divelogsDate(purchaseDate), if (lastServiceDate != null) 'last_servicedate': divelogsDate(lastServiceDate), From 87afd5714937f7e690f01d32a455f421bb90a894 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 00:57:15 -0400 Subject: [PATCH 28/35] feat: sync gear and certifications from the divelogs.de sync page --- .../pages/divelogs_sync_page.dart | 129 ++++++++++++++++- lib/l10n/arb/app_ar.arb | 8 ++ lib/l10n/arb/app_de.arb | 8 ++ lib/l10n/arb/app_en.arb | 14 ++ lib/l10n/arb/app_es.arb | 8 ++ lib/l10n/arb/app_fr.arb | 8 ++ lib/l10n/arb/app_he.arb | 8 ++ lib/l10n/arb/app_hu.arb | 8 ++ lib/l10n/arb/app_it.arb | 8 ++ lib/l10n/arb/app_localizations.dart | 48 +++++++ lib/l10n/arb/app_localizations_ar.dart | 36 +++++ lib/l10n/arb/app_localizations_de.dart | 37 +++++ lib/l10n/arb/app_localizations_en.dart | 36 +++++ lib/l10n/arb/app_localizations_es.dart | 37 +++++ lib/l10n/arb/app_localizations_fr.dart | 37 +++++ lib/l10n/arb/app_localizations_he.dart | 36 +++++ lib/l10n/arb/app_localizations_hu.dart | 37 +++++ lib/l10n/arb/app_localizations_it.dart | 37 +++++ lib/l10n/arb/app_localizations_nl.dart | 37 +++++ lib/l10n/arb/app_localizations_pt.dart | 37 +++++ lib/l10n/arb/app_localizations_zh.dart | 36 +++++ lib/l10n/arb/app_nl.arb | 8 ++ lib/l10n/arb/app_pt.arb | 8 ++ lib/l10n/arb/app_zh.arb | 8 ++ .../pages/divelogs_sync_page_test.dart | 131 ++++++++++++++++++ 25 files changed, 804 insertions(+), 1 deletion(-) diff --git a/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart index fd20338b2c..8b9385526a 100644 --- a/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart +++ b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart @@ -8,10 +8,14 @@ import 'package:submersion/core/services/accounts/adapters/divelogs_account_adap import 'package:submersion/core/services/accounts/connected_account.dart'; import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; import 'package:submersion/core/services/divelogs/divelogs_auth_manager.dart'; +import 'package:submersion/features/certifications/presentation/providers/certification_providers.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart'; import 'package:submersion/features/divelogs_sync/domain/services/divelogs_push_service.dart'; import 'package:submersion/features/divelogs_sync/domain/services/divelogs_sync_planner.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart'; +import 'package:submersion/features/equipment/presentation/providers/equipment_providers.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -46,6 +50,12 @@ class _DivelogsSyncPageState extends ConsumerState { int _pushDone = 0; int _pushTotal = 0; DivelogsPushResult? _lastPushResult; + GearCertSyncPlan? _gearCertPlan; + Map _geartypes = const {}; + Map _remoteGearIdByName = const {}; + String? _gearCertError; + GearCertPushResult? _lastGearCertResult; + bool _pushingGearCerts = false; @override void initState() { @@ -102,7 +112,8 @@ class _DivelogsSyncPageState extends ConsumerState { _errorMessage = null; }); try { - final remote = await _api(account).getDivelist(); + final api = _api(account); + final remote = await api.getDivelist(); final currentDiver = await ref.read(currentDiverProvider.future); final diverId = account.diverId ?? currentDiver?.id; final local = await ref @@ -113,9 +124,46 @@ class _DivelogsSyncPageState extends ConsumerState { remote: remote.entries, local: local, ); + // Gear/certs compare independently: a failure here renders an inline + // error line in that section and never breaks the dive compare. + GearCertSyncPlan? gearCertPlan; + String? gearCertError; + var geartypes = const {}; + var remoteGearIdByName = const {}; + try { + final remoteGear = await api.getGear(); + final remoteCerts = await api.getCertifications(); + try { + geartypes = await api.getGeartypes(); + } on DivelogsApiException { + // Geartype names only refine push mapping; ignore. + } + final localGear = await ref + .read(equipmentRepositoryProvider) + .getAllEquipment(diverId: diverId); + final localCerts = await ref + .read(certificationRepositoryProvider) + .getAllCertifications(diverId: diverId); + gearCertPlan = const GearCertSyncPlanner().plan( + remoteGear: remoteGear, + remoteCerts: remoteCerts, + localGear: localGear, + localCerts: localCerts, + ); + remoteGearIdByName = { + for (final g in remoteGear) g.name.trim().toLowerCase(): g.id, + }; + } on DivelogsApiException catch (e) { + gearCertError = e.message; + } + if (!mounted) return; setState(() { _plan = plan; _selectedPushIds = plan.pushCandidates.map((s) => s.id).toSet(); + _gearCertPlan = gearCertPlan; + _gearCertError = gearCertError; + _geartypes = geartypes; + _remoteGearIdByName = remoteGearIdByName; _phase = _PagePhase.plan; }); } on DivelogsApiException catch (e) { @@ -148,6 +196,7 @@ class _DivelogsSyncPageState extends ConsumerState { if (!mounted) return; final result = await DivelogsPushService(api: _api(account)).push( dives, + remoteGearIdByName: _remoteGearIdByName, onProgress: (done, total) { if (!mounted) return; setState(() { @@ -162,6 +211,24 @@ class _DivelogsSyncPageState extends ConsumerState { await _compare(); } + Future _pushGearCerts() async { + final account = _account; + final gearCertPlan = _gearCertPlan; + if (account == null || gearCertPlan == null || !gearCertPlan.hasPush) { + return; + } + setState(() => _pushingGearCerts = true); + final result = await DivelogsGearCertPushService(api: _api(account)).push( + gear: gearCertPlan.pushGear, + certs: gearCertPlan.pushCerts, + geartypes: _geartypes, + ); + if (!mounted) return; + _lastGearCertResult = result; + _pushingGearCerts = false; + await _compare(); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -362,10 +429,70 @@ class _DivelogsSyncPageState extends ConsumerState { child: Text(l10n.divelogsSync_pushSelected), ), ], + const SizedBox(height: 16), + ..._buildGearCertSection(context), ], ); } + List _buildGearCertSection(BuildContext context) { + final l10n = context.l10n; + final plan = _gearCertPlan; + final error = _gearCertError; + final pushResult = _lastGearCertResult; + return [ + Text( + l10n.divelogsSync_gearCertHeader, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (error != null) + Text( + l10n.divelogsSync_gearCertUnavailable(error), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ) + else if (plan != null) ...[ + if (pushResult != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + pushResult.failed + ? l10n.divelogsSync_gearCertPushFailed(pushResult.error!) + : l10n.divelogsSync_gearCertPushDone( + pushResult.gearPushed, + pushResult.certsPushed, + ), + ), + ), + Text( + l10n.divelogsSync_gearCertMatched( + plan.matchedGear, + plan.matchedCerts, + ), + ), + if (plan.certsMissingDate > 0) + Text( + l10n.divelogsSync_certsMissingDate(plan.certsMissingDate), + style: Theme.of(context).textTheme.bodySmall, + ), + if (plan.hasPush) ...[ + const SizedBox(height: 8), + Text( + l10n.divelogsSync_gearCertPush( + plan.pushGear.length, + plan.pushCerts.length, + ), + ), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: _pushingGearCerts ? null : _pushGearCerts, + child: Text(l10n.divelogsSync_gearCertPushButton), + ), + ], + ], + ]; + } + String _summaryTitle(DiveSummary summary) { final name = summary.name; if (name != null && name.isNotEmpty) return name; diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 51fff48bca..be2c63f38b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "المعدات والشهادات", + "divelogsSync_gearCertMatched": "{gear} قطع معدات و{certs} شهادات متزامنة بالفعل", + "divelogsSync_gearCertPush": "دفع: {gear} قطع معدات، {certs} شهادات", + "divelogsSync_gearCertPushButton": "مزامنة المعدات والشهادات", + "divelogsSync_gearCertPushDone": "تم دفع {gear} قطع معدات و{certs} شهادات.", + "divelogsSync_gearCertPushFailed": "توقف دفع المعدات/الشهادات: {error}", + "divelogsSync_certsMissingDate": "{count} شهادات تحتاج إلى تاريخ إصدار قبل إمكانية دفعها.", + "divelogsSync_gearCertUnavailable": "تعذرت مقارنة المعدات والشهادات: {error}", "divelogsSync_title": "مزامنة divelogs.de", "divelogsSync_notConnected": "لا يوجد حساب divelogs.de متصل بعد. ابدأ استيرادا لتسجيل الدخول.", "divelogsSync_openImport": "فتح استيراد divelogs.de", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 7dc1a28668..2aad3544b9 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "Ausrüstung & Zertifizierungen", + "divelogsSync_gearCertMatched": "{gear} Ausrüstungsteile und {certs} Zertifizierungen bereits synchron", + "divelogsSync_gearCertPush": "Senden: {gear} Ausrüstungsteile, {certs} Zertifizierungen", + "divelogsSync_gearCertPushButton": "Ausrüstung & Zertifizierungen synchronisieren", + "divelogsSync_gearCertPushDone": "{gear} Ausrüstungsteile und {certs} Zertifizierungen gesendet.", + "divelogsSync_gearCertPushFailed": "Senden der Ausrüstung/Zertifizierungen gestoppt: {error}", + "divelogsSync_certsMissingDate": "{count} Zertifizierungen benötigen ein Ausstellungsdatum, bevor sie gesendet werden können.", + "divelogsSync_gearCertUnavailable": "Ausrüstung und Zertifizierungen konnten nicht verglichen werden: {error}", "divelogsSync_title": "divelogs.de-Synchronisierung", "divelogsSync_notConnected": "Es ist noch kein divelogs.de-Konto verbunden. Starten Sie einen Import, um sich anzumelden.", "divelogsSync_openImport": "divelogs.de-Import öffnen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 22d2e2d176..1dd53a3455 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,18 @@ { + "divelogsSync_gearCertHeader": "Gear & certifications", + "divelogsSync_gearCertMatched": "{gear} gear items and {certs} certifications already in sync", + "@divelogsSync_gearCertMatched": {"placeholders": {"gear": {"type": "int"}, "certs": {"type": "int"}}}, + "divelogsSync_gearCertPush": "Push: {gear} gear items, {certs} certifications", + "@divelogsSync_gearCertPush": {"placeholders": {"gear": {"type": "int"}, "certs": {"type": "int"}}}, + "divelogsSync_gearCertPushButton": "Sync gear & certifications", + "divelogsSync_gearCertPushDone": "Pushed {gear} gear items and {certs} certifications.", + "@divelogsSync_gearCertPushDone": {"placeholders": {"gear": {"type": "int"}, "certs": {"type": "int"}}}, + "divelogsSync_gearCertPushFailed": "Gear/certification push stopped: {error}", + "@divelogsSync_gearCertPushFailed": {"placeholders": {"error": {"type": "String"}}}, + "divelogsSync_certsMissingDate": "{count} certifications need an issue date before they can be pushed.", + "@divelogsSync_certsMissingDate": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_gearCertUnavailable": "Gear and certifications could not be compared: {error}", + "@divelogsSync_gearCertUnavailable": {"placeholders": {"error": {"type": "String"}}}, "divelogsSync_title": "divelogs.de Sync", "divelogsSync_notConnected": "No divelogs.de account is connected yet. Start an import to sign in.", "divelogsSync_openImport": "Open divelogs.de import", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index ac35e7d7a4..ee935ab68e 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "Equipo y certificaciones", + "divelogsSync_gearCertMatched": "{gear} equipos y {certs} certificaciones ya sincronizados", + "divelogsSync_gearCertPush": "Subir: {gear} equipos, {certs} certificaciones", + "divelogsSync_gearCertPushButton": "Sincronizar equipo y certificaciones", + "divelogsSync_gearCertPushDone": "Se subieron {gear} equipos y {certs} certificaciones.", + "divelogsSync_gearCertPushFailed": "La subida de equipo/certificaciones se detuvo: {error}", + "divelogsSync_certsMissingDate": "{count} certificaciones necesitan una fecha de emisión antes de poder subirse.", + "divelogsSync_gearCertUnavailable": "No se pudieron comparar el equipo y las certificaciones: {error}", "divelogsSync_title": "Sincronización con divelogs.de", "divelogsSync_notConnected": "Aún no hay una cuenta de divelogs.de conectada. Inicia una importación para iniciar sesión.", "divelogsSync_openImport": "Abrir importación de divelogs.de", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index ac449a96d1..e26b720112 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "Équipement et certifications", + "divelogsSync_gearCertMatched": "{gear} équipements et {certs} certifications déjà synchronisés", + "divelogsSync_gearCertPush": "Envoyer : {gear} équipements, {certs} certifications", + "divelogsSync_gearCertPushButton": "Synchroniser équipement et certifications", + "divelogsSync_gearCertPushDone": "{gear} équipements et {certs} certifications envoyés.", + "divelogsSync_gearCertPushFailed": "Envoi équipement/certifications arrêté : {error}", + "divelogsSync_certsMissingDate": "{count} certifications nécessitent une date de délivrance avant de pouvoir être envoyées.", + "divelogsSync_gearCertUnavailable": "Impossible de comparer l'équipement et les certifications : {error}", "divelogsSync_title": "Synchronisation divelogs.de", "divelogsSync_notConnected": "Aucun compte divelogs.de n'est encore connecté. Lancez une importation pour vous connecter.", "divelogsSync_openImport": "Ouvrir l'importation divelogs.de", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 55a4409731..c6787d7c8d 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "ציוד והסמכות", + "divelogsSync_gearCertMatched": "{gear} פריטי ציוד ו-{certs} הסמכות כבר מסונכרנים", + "divelogsSync_gearCertPush": "דחיפה: {gear} פריטי ציוד, {certs} הסמכות", + "divelogsSync_gearCertPushButton": "סנכרן ציוד והסמכות", + "divelogsSync_gearCertPushDone": "נדחפו {gear} פריטי ציוד ו-{certs} הסמכות.", + "divelogsSync_gearCertPushFailed": "דחיפת ציוד/הסמכות נעצרה: {error}", + "divelogsSync_certsMissingDate": "{count} הסמכות זקוקות לתאריך הנפקה לפני שניתן לדחוף אותן.", + "divelogsSync_gearCertUnavailable": "לא ניתן להשוות ציוד והסמכות: {error}", "divelogsSync_title": "סנכרון divelogs.de", "divelogsSync_notConnected": "עדיין לא מחובר חשבון divelogs.de. התחל ייבוא כדי להתחבר.", "divelogsSync_openImport": "פתח ייבוא divelogs.de", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 40a863e22d..e205f1478a 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "Felszerelés és minősítések", + "divelogsSync_gearCertMatched": "{gear} felszerelés és {certs} minősítés már szinkronban", + "divelogsSync_gearCertPush": "Feltöltés: {gear} felszerelés, {certs} minősítés", + "divelogsSync_gearCertPushButton": "Felszerelés és minősítések szinkronizálása", + "divelogsSync_gearCertPushDone": "{gear} felszerelés és {certs} minősítés feltöltve.", + "divelogsSync_gearCertPushFailed": "A felszerelés/minősítés feltöltése leállt: {error}", + "divelogsSync_certsMissingDate": "{count} minősítéshez kiállítási dátum szükséges a feltöltés előtt.", + "divelogsSync_gearCertUnavailable": "A felszerelés és a minősítések összehasonlítása nem sikerült: {error}", "divelogsSync_title": "divelogs.de szinkronizálás", "divelogsSync_notConnected": "Még nincs csatlakoztatott divelogs.de-fiók. Indíts egy importot a bejelentkezéshez.", "divelogsSync_openImport": "divelogs.de-import megnyitása", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index b92a728146..f4559831e5 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "Attrezzatura e certificazioni", + "divelogsSync_gearCertMatched": "{gear} attrezzature e {certs} certificazioni già sincronizzate", + "divelogsSync_gearCertPush": "Carica: {gear} attrezzature, {certs} certificazioni", + "divelogsSync_gearCertPushButton": "Sincronizza attrezzatura e certificazioni", + "divelogsSync_gearCertPushDone": "Caricate {gear} attrezzature e {certs} certificazioni.", + "divelogsSync_gearCertPushFailed": "Caricamento attrezzatura/certificazioni interrotto: {error}", + "divelogsSync_certsMissingDate": "{count} certificazioni richiedono una data di rilascio prima di poter essere caricate.", + "divelogsSync_gearCertUnavailable": "Impossibile confrontare attrezzatura e certificazioni: {error}", "divelogsSync_title": "Sincronizzazione divelogs.de", "divelogsSync_notConnected": "Nessun account divelogs.de collegato. Avvia un'importazione per accedere.", "divelogsSync_openImport": "Apri importazione divelogs.de", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index e19718c24c..5a29c740ab 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -116,6 +116,54 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @divelogsSync_gearCertHeader. + /// + /// In en, this message translates to: + /// **'Gear & certifications'** + String get divelogsSync_gearCertHeader; + + /// No description provided for @divelogsSync_gearCertMatched. + /// + /// In en, this message translates to: + /// **'{gear} gear items and {certs} certifications already in sync'** + String divelogsSync_gearCertMatched(int gear, int certs); + + /// No description provided for @divelogsSync_gearCertPush. + /// + /// In en, this message translates to: + /// **'Push: {gear} gear items, {certs} certifications'** + String divelogsSync_gearCertPush(int gear, int certs); + + /// No description provided for @divelogsSync_gearCertPushButton. + /// + /// In en, this message translates to: + /// **'Sync gear & certifications'** + String get divelogsSync_gearCertPushButton; + + /// No description provided for @divelogsSync_gearCertPushDone. + /// + /// In en, this message translates to: + /// **'Pushed {gear} gear items and {certs} certifications.'** + String divelogsSync_gearCertPushDone(int gear, int certs); + + /// No description provided for @divelogsSync_gearCertPushFailed. + /// + /// In en, this message translates to: + /// **'Gear/certification push stopped: {error}'** + String divelogsSync_gearCertPushFailed(String error); + + /// No description provided for @divelogsSync_certsMissingDate. + /// + /// In en, this message translates to: + /// **'{count} certifications need an issue date before they can be pushed.'** + String divelogsSync_certsMissingDate(int count); + + /// No description provided for @divelogsSync_gearCertUnavailable. + /// + /// In en, this message translates to: + /// **'Gear and certifications could not be compared: {error}'** + String divelogsSync_gearCertUnavailable(String error); + /// No description provided for @divelogsSync_title. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index fbec40ef2e..45d4da5a37 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8,6 +8,42 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'المعدات والشهادات'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear قطع معدات و$certs شهادات متزامنة بالفعل'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'دفع: $gear قطع معدات، $certs شهادات'; + } + + @override + String get divelogsSync_gearCertPushButton => 'مزامنة المعدات والشهادات'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return 'تم دفع $gear قطع معدات و$certs شهادات.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'توقف دفع المعدات/الشهادات: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count شهادات تحتاج إلى تاريخ إصدار قبل إمكانية دفعها.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'تعذرت مقارنة المعدات والشهادات: $error'; + } + @override String get divelogsSync_title => 'مزامنة divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 6a8ef0539b..47859cc9a6 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Ausrüstung & Zertifizierungen'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear Ausrüstungsteile und $certs Zertifizierungen bereits synchron'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Senden: $gear Ausrüstungsteile, $certs Zertifizierungen'; + } + + @override + String get divelogsSync_gearCertPushButton => + 'Ausrüstung & Zertifizierungen synchronisieren'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return '$gear Ausrüstungsteile und $certs Zertifizierungen gesendet.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'Senden der Ausrüstung/Zertifizierungen gestoppt: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count Zertifizierungen benötigen ein Ausstellungsdatum, bevor sie gesendet werden können.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'Ausrüstung und Zertifizierungen konnten nicht verglichen werden: $error'; + } + @override String get divelogsSync_title => 'divelogs.de-Synchronisierung'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 7fbc99df37..4694a1bfa7 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8,6 +8,42 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Gear & certifications'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear gear items and $certs certifications already in sync'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Push: $gear gear items, $certs certifications'; + } + + @override + String get divelogsSync_gearCertPushButton => 'Sync gear & certifications'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return 'Pushed $gear gear items and $certs certifications.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'Gear/certification push stopped: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count certifications need an issue date before they can be pushed.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'Gear and certifications could not be compared: $error'; + } + @override String get divelogsSync_title => 'divelogs.de Sync'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index f704153cee..8b273d4bf0 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Equipo y certificaciones'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear equipos y $certs certificaciones ya sincronizados'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Subir: $gear equipos, $certs certificaciones'; + } + + @override + String get divelogsSync_gearCertPushButton => + 'Sincronizar equipo y certificaciones'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return 'Se subieron $gear equipos y $certs certificaciones.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'La subida de equipo/certificaciones se detuvo: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count certificaciones necesitan una fecha de emisión antes de poder subirse.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'No se pudieron comparar el equipo y las certificaciones: $error'; + } + @override String get divelogsSync_title => 'Sincronización con divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 7abe17fc1e..725bedb36c 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Équipement et certifications'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear équipements et $certs certifications déjà synchronisés'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Envoyer : $gear équipements, $certs certifications'; + } + + @override + String get divelogsSync_gearCertPushButton => + 'Synchroniser équipement et certifications'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return '$gear équipements et $certs certifications envoyés.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'Envoi équipement/certifications arrêté : $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count certifications nécessitent une date de délivrance avant de pouvoir être envoyées.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'Impossible de comparer l\'équipement et les certifications : $error'; + } + @override String get divelogsSync_title => 'Synchronisation divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index ec42be3d48..9e1848dc08 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8,6 +8,42 @@ import 'app_localizations.dart'; class AppLocalizationsHe extends AppLocalizations { AppLocalizationsHe([String locale = 'he']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'ציוד והסמכות'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear פריטי ציוד ו-$certs הסמכות כבר מסונכרנים'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'דחיפה: $gear פריטי ציוד, $certs הסמכות'; + } + + @override + String get divelogsSync_gearCertPushButton => 'סנכרן ציוד והסמכות'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return 'נדחפו $gear פריטי ציוד ו-$certs הסמכות.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'דחיפת ציוד/הסמכות נעצרה: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count הסמכות זקוקות לתאריך הנפקה לפני שניתן לדחוף אותן.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'לא ניתן להשוות ציוד והסמכות: $error'; + } + @override String get divelogsSync_title => 'סנכרון divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index f1a86c6bc5..45010652ad 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsHu extends AppLocalizations { AppLocalizationsHu([String locale = 'hu']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Felszerelés és minősítések'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear felszerelés és $certs minősítés már szinkronban'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Feltöltés: $gear felszerelés, $certs minősítés'; + } + + @override + String get divelogsSync_gearCertPushButton => + 'Felszerelés és minősítések szinkronizálása'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return '$gear felszerelés és $certs minősítés feltöltve.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'A felszerelés/minősítés feltöltése leállt: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count minősítéshez kiállítási dátum szükséges a feltöltés előtt.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'A felszerelés és a minősítések összehasonlítása nem sikerült: $error'; + } + @override String get divelogsSync_title => 'divelogs.de szinkronizálás'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 62e8e25cfd..24aa307d80 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Attrezzatura e certificazioni'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear attrezzature e $certs certificazioni già sincronizzate'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Carica: $gear attrezzature, $certs certificazioni'; + } + + @override + String get divelogsSync_gearCertPushButton => + 'Sincronizza attrezzatura e certificazioni'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return 'Caricate $gear attrezzature e $certs certificazioni.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'Caricamento attrezzatura/certificazioni interrotto: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count certificazioni richiedono una data di rilascio prima di poter essere caricate.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'Impossibile confrontare attrezzatura e certificazioni: $error'; + } + @override String get divelogsSync_title => 'Sincronizzazione divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 15fcc68d20..2647800036 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Uitrusting & brevetten'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear uitrustingsstukken en $certs brevetten al gesynchroniseerd'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Versturen: $gear uitrustingsstukken, $certs brevetten'; + } + + @override + String get divelogsSync_gearCertPushButton => + 'Uitrusting & brevetten synchroniseren'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return '$gear uitrustingsstukken en $certs brevetten verstuurd.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'Versturen van uitrusting/brevetten gestopt: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count brevetten hebben een uitgiftedatum nodig voordat ze verstuurd kunnen worden.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'Uitrusting en brevetten konden niet worden vergeleken: $error'; + } + @override String get divelogsSync_title => 'divelogs.de-synchronisatie'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 7ae08fd587..463761c9da 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8,6 +8,43 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get divelogsSync_gearCertHeader => 'Equipamento e certificações'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear equipamentos e $certs certificações já sincronizados'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return 'Enviar: $gear equipamentos, $certs certificações'; + } + + @override + String get divelogsSync_gearCertPushButton => + 'Sincronizar equipamento e certificações'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return '$gear equipamentos e $certs certificações enviados.'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return 'Envio de equipamento/certificações interrompido: $error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count certificações precisam de uma data de emissão antes de poderem ser enviadas.'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return 'Não foi possível comparar equipamento e certificações: $error'; + } + @override String get divelogsSync_title => 'Sincronização divelogs.de'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index b60bea200d..116be3b2bb 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8,6 +8,42 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get divelogsSync_gearCertHeader => '装备与证书'; + + @override + String divelogsSync_gearCertMatched(int gear, int certs) { + return '$gear 件装备和 $certs 张证书已同步'; + } + + @override + String divelogsSync_gearCertPush(int gear, int certs) { + return '推送:$gear 件装备,$certs 张证书'; + } + + @override + String get divelogsSync_gearCertPushButton => '同步装备与证书'; + + @override + String divelogsSync_gearCertPushDone(int gear, int certs) { + return '已推送 $gear 件装备和 $certs 张证书。'; + } + + @override + String divelogsSync_gearCertPushFailed(String error) { + return '装备/证书推送已停止:$error'; + } + + @override + String divelogsSync_certsMissingDate(int count) { + return '$count 张证书需要签发日期后才能推送。'; + } + + @override + String divelogsSync_gearCertUnavailable(String error) { + return '无法比较装备与证书:$error'; + } + @override String get divelogsSync_title => 'divelogs.de 同步'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 551197410f..183a086cdb 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "Uitrusting & brevetten", + "divelogsSync_gearCertMatched": "{gear} uitrustingsstukken en {certs} brevetten al gesynchroniseerd", + "divelogsSync_gearCertPush": "Versturen: {gear} uitrustingsstukken, {certs} brevetten", + "divelogsSync_gearCertPushButton": "Uitrusting & brevetten synchroniseren", + "divelogsSync_gearCertPushDone": "{gear} uitrustingsstukken en {certs} brevetten verstuurd.", + "divelogsSync_gearCertPushFailed": "Versturen van uitrusting/brevetten gestopt: {error}", + "divelogsSync_certsMissingDate": "{count} brevetten hebben een uitgiftedatum nodig voordat ze verstuurd kunnen worden.", + "divelogsSync_gearCertUnavailable": "Uitrusting en brevetten konden niet worden vergeleken: {error}", "divelogsSync_title": "divelogs.de-synchronisatie", "divelogsSync_notConnected": "Er is nog geen divelogs.de-account gekoppeld. Start een import om aan te melden.", "divelogsSync_openImport": "divelogs.de-import openen", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 19ef2d4e58..63e8ec5dba 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "Equipamento e certificações", + "divelogsSync_gearCertMatched": "{gear} equipamentos e {certs} certificações já sincronizados", + "divelogsSync_gearCertPush": "Enviar: {gear} equipamentos, {certs} certificações", + "divelogsSync_gearCertPushButton": "Sincronizar equipamento e certificações", + "divelogsSync_gearCertPushDone": "{gear} equipamentos e {certs} certificações enviados.", + "divelogsSync_gearCertPushFailed": "Envio de equipamento/certificações interrompido: {error}", + "divelogsSync_certsMissingDate": "{count} certificações precisam de uma data de emissão antes de poderem ser enviadas.", + "divelogsSync_gearCertUnavailable": "Não foi possível comparar equipamento e certificações: {error}", "divelogsSync_title": "Sincronização divelogs.de", "divelogsSync_notConnected": "Nenhuma conta divelogs.de conectada ainda. Inicie uma importação para entrar.", "divelogsSync_openImport": "Abrir importação do divelogs.de", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 2cfa8a0170..57e662f348 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1,4 +1,12 @@ { + "divelogsSync_gearCertHeader": "装备与证书", + "divelogsSync_gearCertMatched": "{gear} 件装备和 {certs} 张证书已同步", + "divelogsSync_gearCertPush": "推送:{gear} 件装备,{certs} 张证书", + "divelogsSync_gearCertPushButton": "同步装备与证书", + "divelogsSync_gearCertPushDone": "已推送 {gear} 件装备和 {certs} 张证书。", + "divelogsSync_gearCertPushFailed": "装备/证书推送已停止:{error}", + "divelogsSync_certsMissingDate": "{count} 张证书需要签发日期后才能推送。", + "divelogsSync_gearCertUnavailable": "无法比较装备与证书:{error}", "divelogsSync_title": "divelogs.de 同步", "divelogsSync_notConnected": "尚未连接 divelogs.de 账户。请开始导入以登录。", "divelogsSync_openImport": "打开 divelogs.de 导入", diff --git a/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart b/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart index cad894d4d2..236c8fa327 100644 --- a/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart +++ b/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart @@ -5,8 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/data/repositories/connected_accounts_repository.dart'; import 'package:submersion/core/providers/account_providers.dart'; +import 'package:submersion/features/certifications/data/repositories/certification_repository.dart'; +import 'package:submersion/features/certifications/domain/entities/certification.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/domain/entities/equipment_item.dart'; import 'package:submersion/core/services/accounts/account_kind.dart'; import 'package:submersion/core/services/accounts/account_credentials_store.dart'; import 'package:submersion/core/services/divelogs/divelogs_credentials.dart'; @@ -111,6 +116,18 @@ void main() { ); } + /// Wraps a per-path handler with empty defaults for the gear/cert + /// endpoints the compare step now always queries. + Future? gearCertDefaults(http.Request req) { + switch (req.url.path) { + case '/api/gear': + case '/api/geartypes': + case '/api/certifications': + return Future.value(http.Response(jsonEncode([]), 200)); + } + return null; + } + testWidgets('shows connect prompt when no account exists', (tester) async { await tester.runAsync(() async { await tester.pumpWidget( @@ -130,6 +147,8 @@ void main() { testWidgets('compare renders pull/push/matched sections', (tester) async { final client = MockClient((req) async { + final fallback = gearCertDefaults(req); + if (fallback != null) return fallback; if (req.url.path == '/api/divelist') { return http.Response( jsonEncode([ @@ -171,6 +190,8 @@ void main() { var divelistCalls = 0; List? postedBody; final client = MockClient((req) async { + final fallback = gearCertDefaults(req); + if (fallback != null) return fallback; if (req.url.path == '/api/divelist') { divelistCalls++; if (divelistCalls >= 2) { @@ -214,4 +235,114 @@ void main() { expect(divelistCalls, 2, reason: 'push triggers an automatic re-compare'); expect(find.textContaining('Pushed 1 dives'), findsOneWidget); }); + + testWidgets('compare shows gear/cert push counts', (tester) async { + final client = MockClient((req) async { + switch (req.url.path) { + case '/api/divelist': + case '/api/gear': + case '/api/geartypes': + case '/api/certifications': + return http.Response(jsonEncode([]), 200); + } + fail('unexpected request ${req.url}'); + }); + + await tester.runAsync(() async { + await seedAccount(); + await EquipmentRepository().createEquipment( + const EquipmentItem( + id: 'e1', + diverId: 'diver-1', + name: 'Apex XTX50', + type: EquipmentType.regulator, + ), + ); + await CertificationRepository().createCertification( + Certification( + id: 'c1', + diverId: 'diver-1', + name: 'Open Water', + agency: CertificationAgency.padi, + issueDate: DateTime.utc(2022, 6, 15), + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), + ), + ); + await tester.pumpWidget(host(client)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Compare')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + }); + + expect(find.text('Gear & certifications'), findsOneWidget); + expect(find.text('Push: 1 gear items, 1 certifications'), findsOneWidget); + expect(find.text('Sync gear & certifications'), findsOneWidget); + }); + + testWidgets('gear/cert push posts both and re-compares', (tester) async { + var gearPosts = 0; + var certPosts = 0; + var divelistCalls = 0; + final client = MockClient((req) async { + switch ((req.method, req.url.path)) { + case ('GET', '/api/divelist'): + divelistCalls++; + return http.Response(jsonEncode([]), 200); + case ('GET', '/api/gear'): + case ('GET', '/api/geartypes'): + case ('GET', '/api/certifications'): + return http.Response(jsonEncode([]), 200); + case ('POST', '/api/gear'): + gearPosts++; + return http.Response('{}', 200); + case ('POST', '/api/certifications'): + certPosts++; + return http.Response('{}', 200); + } + fail('unexpected request ${req.method} ${req.url}'); + }); + + await tester.runAsync(() async { + await seedAccount(); + await EquipmentRepository().createEquipment( + const EquipmentItem( + id: 'e1', + diverId: 'diver-1', + name: 'Apex XTX50', + type: EquipmentType.regulator, + ), + ); + await CertificationRepository().createCertification( + Certification( + id: 'c1', + diverId: 'diver-1', + name: 'Open Water', + agency: CertificationAgency.padi, + issueDate: DateTime.utc(2022, 6, 15), + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), + ), + ); + await tester.pumpWidget(host(client)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Compare')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Sync gear & certifications')); + await tester.tap(find.text('Sync gear & certifications')); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + }); + + expect(gearPosts, 1); + expect(certPosts, 1); + expect(divelistCalls, 2, reason: 'gear/cert push re-compares'); + expect( + find.text('Pushed 1 gear items and 1 certifications.'), + findsOneWidget, + ); + }); } From bab38f83df03edcd28bd5e8db7cf83b660159d84 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 12:34:37 -0400 Subject: [PATCH 29/35] docs: add divelogs.de sync phase 4 implementation plan --- .../plans/2026-07-17-divelogs-sync-phase4.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-divelogs-sync-phase4.md diff --git a/docs/superpowers/plans/2026-07-17-divelogs-sync-phase4.md b/docs/superpowers/plans/2026-07-17-divelogs-sync-phase4.md new file mode 100644 index 0000000000..0730846637 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-divelogs-sync-phase4.md @@ -0,0 +1,165 @@ +# divelogs.de Sync — Phase 4 (Dive Pictures) 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:** Sync dive photos with divelogs.de for matched dives — pull remote pictures into a dive's local media (deduped by SHA-256 content hash), push local photos for dives that have no remote pictures yet. + +**Architecture:** The dive sync planner starts reporting matched remote↔local pairs. A `DivelogsPhotoSyncService` (function-injected dependencies, fully unit-testable) walks each pair: downloads remote pictures with usable URLs, hashes them, attaches new ones via the existing `MediaImportService.importLocalFileForDive` path, and pushes local photos via multipart `POST /pictures/{dive_id}` — but only for dives with zero remote pictures (the create-only duplicate guard, since remote content hashes don't exist). The sync page gains a Photos section. + +**Tech Stack:** Same as Phases 1–3 plus `package:crypto` (`sha256`, already a dependency via `store_keys.dart`). No schema migration. + +**Spec:** `docs/superpowers/specs/2026-07-16-divelogs-de-sync-design.md` (Phase 4). + +## Global Constraints + +- Same as Phases 1–3 (metric, wall-clock UTC, format/analyze clean, no emojis, no attribution, l10n all 11 locales + `flutter gen-l10n`, per-file tests, `--no-verify` push, backup-test full-suite flake protocol). +- API (verified from OpenAPI): `GET /pictures/{dive_id}` (response shape UNDOCUMENTED), `POST /pictures/{dive_id}` multipart with required binary field `imagefile`, `DELETE /pictures/{picture_id}` (never used — create-only). +- Picture download URLs are unconfirmed: parse each picture row tolerantly; rows whose URL cannot be resolved to an absolute http(s) URI are counted and surfaced as "skipped", never fetched by guessed paths. +- Create-only, both directions. Pull dedup: SHA-256 of downloaded bytes vs hashes of the dive's existing local photos (computed on the fly — there is no DB hash lookup). Push guard: only for matched dives whose remote picture list is EMPTY (remote hashes don't exist, so count-zero is the only safe duplicate guard). +- **Certification scans are OUT of scope** (deliberate deviation from the spec's "pictures" phase breadth): their download URL scheme is undocumented and the API only accepts scans at certification creation. Revisit when Rainer answers open question 9; record this in the code comment on the photo service. +- Photos sync only from the sync page, after a compare (pairs come from the compare); the import wizard flow is untouched. +- One-button flow over all matched pairs (no per-dive photo checkboxes) — the same deliberate simplification as gear/certs: the count-zero push guard and hash-dedup pull make the operation safe to run wholesale, and per-dive selection can be added on user feedback. The spec's "dives the user selects" is satisfied at the coarser granularity of the explicit button press. + +--- + +### Task 1: Picture model + API endpoints + +**Files:** +- Modify: `lib/core/services/divelogs/divelogs_models.dart` +- Modify: `lib/core/services/divelogs/divelogs_api_client.dart` +- Test: extend `divelogs_models_test.dart` and `divelogs_api_client_test.dart` + +**Interfaces:** +- Produces: + - `class DivelogsPicture { final String? id; final Uri? url; static DivelogsPicture? fromJson(Map); }` — `url` is the first of `url`/`link`/`href`/`imageurl` that parses as an absolute http(s) URI (`Uri.tryParse` + `isScheme('http')||isScheme('https')`); a bare filename yields `url == null` (row kept, counted as unusable by callers); a row with neither id nor any recognized key yields null. + - On the client: + - `Future> getPictures(String diveId)` — GET `/pictures/$diveId` via `_get`, rows via `_rows(decoded, '/pictures', const ['pictures'])`, null models skipped. + - `Future downloadPictureBytes(Uri url)` — plain authorized GET via `_http.get(url, headers: {'Authorization': 'Bearer $token'})` with the standard 401-invalidate-retry-once loop (absolute URL, so NOT through `_send`'s path builder); non-2xx → `DivelogsApiException`; returns `response.bodyBytes`. + - `Future postPicture(String diveId, {required List bytes, required String filename})` — multipart POST to `/pictures/$diveId` with `http.MultipartFile.fromBytes('imagefile', bytes, filename: filename)`, request rebuilt per attempt for the 401 retry (same loop as `postCertification`). + +- [ ] **Step 1: Write failing tests** — model: url picked from `url` then `link`; bare-filename row keeps id with null url; junk row → null. Client: `getPictures` parses an array and a `{pictures: [...]}` wrapper; `downloadPictureBytes` sends the bearer header to the EXACT absolute URL and returns bytes, retries once on 401; `postPicture` sends multipart with an `imagefile` file part (use the `_CapturingClient` from the existing cert tests — assert `captured.files.single.field == 'imagefile'` and filename) and retries once on 401. Full test code in the established file styles. +- [ ] **Step 2: Run red, implement, run green** — `flutter test test/core/services/divelogs/`. +- [ ] **Step 3: Commit** — `feat: add divelogs.de picture endpoints with tolerant URL parsing` + +--- + +### Task 2: Matched pairs on the dive sync planner + +**Files:** +- Modify: `lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart` +- Test: extend `divelogs_sync_planner_test.dart` + +**Interfaces:** +- Produces: `class DivelogsMatchedDive { final String remoteId; final String localDiveId; final DateTime localTime; }` and `DivelogsSyncPlan` gains `final List matchedPairs;` (populated where `matched++` happens today: `remoteId: entry.id`, `localDiveId: best.id`, `localTime: best.entryTime ?? best.dateTime`). `matchedCount` stays (== `matchedPairs.length`) so existing callers are untouched. + +- [ ] **Step 1: Extend a test** — the existing "matched pairs are neither pulled nor pushed" test additionally asserts `plan.matchedPairs.single.remoteId == 'r1'` and `.localDiveId == 'l1'`. +- [ ] **Step 2: Run red, implement, run green** — `flutter test test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart`. +- [ ] **Step 3: Commit** — `feat: expose matched remote-local dive pairs from the sync planner` + +--- + +### Task 3: `DivelogsPhotoSyncService` + +**Files:** +- Create: `lib/features/divelogs_sync/domain/services/divelogs_photo_sync_service.dart` +- Test: `test/features/divelogs_sync/domain/services/divelogs_photo_sync_service_test.dart` + +**Interfaces:** +- Consumes: Task 1 endpoints, Task 2 `DivelogsMatchedDive`, `MediaItem`/`MediaType` (`lib/features/media/domain/entities/media_item.dart`), `sha256` from `package:crypto`. +- Produces: + +```dart +class PhotoSyncResult { + final int pulled; + final int pulledDuplicates; // downloaded but hash-matched existing + final int skippedNoUrl; // rows without a usable absolute URL + final int pushed; + final String? error; // partial-failure message, stops the run + bool get failed => error != null; +} + +class DivelogsPhotoSyncService { + DivelogsPhotoSyncService({ + required DivelogsApiClient api, + required Future> Function(String diveId) getLocalMedia, + required Future Function(MediaItem item) resolveLocalBytes, + required Future Function({ + required Uint8List bytes, + required String filename, + required String diveId, + required DateTime takenAt, + }) attachToDive, + }); + + Future sync( + List pairs, { + void Function(int done, int total)? onProgress, + }); +} +``` + +- Function-injected dependencies keep the service free of repository/resolver plumbing (the page wires them in Task 4); `resolveLocalBytes` returns null for unresolvable items (they simply don't contribute a hash and are never pushed). +- Per pair, in order: + 1. `remote = await api.getPictures(pair.remoteId)`; split into `withUrl` / `withoutUrl` (count the latter into `skippedNoUrl`). + 2. `local = await getLocalMedia(pair.localDiveId)` filtered to `mediaType == MediaType.photo`; `localHashes = { sha256 of each resolveLocalBytes(item) that returns non-null }`. + 3. Pull: for each remote picture with a URL — `bytes = await api.downloadPictureBytes(url)`; `hash = sha256.convert(bytes).toString()`; if `localHashes` contains it → `pulledDuplicates++`; else `attachToDive(bytes:, filename: .jpg'>, diveId: pair.localDiveId, takenAt: pair.localTime)`, add the hash to `localHashes`, `pulled++`. + 4. Push: only when `remote.isEmpty` and the dive has local photos — for each local photo whose `resolveLocalBytes` returns bytes: `api.postPicture(pair.remoteId, bytes:, filename: item.originalFilename ?? '.jpg')`, `pushed++`. + 5. `onProgress(pairIndex + 1, pairs.length)` after each pair. +- A `DivelogsApiException` anywhere stops the run and returns partial counts with the message (stateless convergence: re-running skips already-pulled photos by hash and already-pictured dives by the count-zero guard). + +- [ ] **Step 1: Write the failing test** — fake `api` via `_CapturingClient`-style MockClient serving `/api/pictures/` GET (with `url` fields pointing at a fake host also served by the mock returning distinct bytes) and POST; in-memory `getLocalMedia`/`resolveLocalBytes`/`attachToDive` recording calls. Cases: (a) pull attaches a new photo and reports `pulled == 1`, filename from the URL path; (b) a remote picture whose bytes hash-match an existing local photo is counted in `pulledDuplicates` and not attached; (c) rows without a usable URL count into `skippedNoUrl`; (d) push happens only when the remote list is empty — one pair with remote pictures gets no POST, one pair with zero remote pictures POSTs each resolvable local photo (`pushed` counts, `imagefile` field asserted); (e) a 500 mid-run stops and reports partial counts + error. Full test code in the established style. +- [ ] **Step 2: Run red, implement, run green** — `flutter test test/features/divelogs_sync/`. +- [ ] **Step 3: Commit** — `feat: add create-only divelogs.de photo sync with hash dedup` + +--- + +### Task 4: Sync page Photos section + l10n + +**Files:** +- Modify: `lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart` +- Modify: `lib/l10n/arb/app_en.arb` + all 10 non-English arb files +- Test: extend `divelogs_sync_page_test.dart` + +**Interfaces:** +- Consumes: Tasks 1–3; `mediaRepositoryProvider` + `MediaRepository.getMediaForDive` (`lib/features/media/presentation/providers/media_providers.dart:7`), `mediaImportServiceProvider` + `MediaImportService.importLocalFileForDive` (`lib/features/media/presentation/providers/photo_picker_providers.dart:239`), `MediaSourceResolverRegistry` (`lib/features/media/data/services/media_source_resolver_registry.dart` — check its provider/construction at the import sites and reuse; `resolve` returns the sealed `MediaSourceData`: use `FileData.file.readAsBytes()`, `BytesData.bytes`, null otherwise). +- Page wiring: + - `_compare` already stores the plan; it now also keeps `_plan!.matchedPairs`. + - New `_syncPhotos()`: guards on a non-empty `matchedPairs`; builds the service with `attachToDive` writing bytes to a temp file (`Directory.systemTemp.createTemp('divelogs_photo')` → `File('/')..writeAsBytes`) then `importLocalFileForDive(sourceFile: file, diveId: diveId, takenAt: takenAt)`; runs with a progress indicator (`_photoSyncDone/_photoSyncTotal`); stores `PhotoSyncResult? _lastPhotoResult`; does NOT re-compare (photos don't change the dive diff). + - Photos section in `_buildPlan` (after gear/certs): "Photos" header; "Sync photos for {count} matched dives" button (disabled when `matchedPairs` empty or `_syncingPhotos`); result line "Pulled {pulled} photos, pushed {pushed}." plus optional captions for `pulledDuplicates` (already present), `skippedNoUrl`, and the failure message. + +New l10n keys (en; translate into all 10 locales with proper diacritics, placeholders typed): + +```json +"divelogsSync_photosHeader": "Photos", +"divelogsSync_photosButton": "Sync photos for {count} matched dives", +"divelogsSync_photosSyncing": "Syncing photos with divelogs.de...", +"divelogsSync_photosDone": "Pulled {pulled} photos, pushed {pushed}.", +"divelogsSync_photosDuplicates": "{count} photos were already present (matched by content).", +"divelogsSync_photosNoUrl": "{count} remote pictures had no downloadable link and were skipped.", +"divelogsSync_photosFailed": "Photo sync stopped: {error}" +``` + +- [ ] **Step 1: Extend the widget test** — MockClient additionally serves `GET /api/pictures/` (empty list) plus one test where a matched pair exists (reuse the compare seeding from the existing matched-dive test), the Photos button shows "Sync photos for 1 matched dives", tapping it calls the pictures endpoint and renders "Pulled 0 photos, pushed 0." (empty both sides keeps the widget test light — service-level behavior is covered by Task 3). Full test in the file's style. +- [ ] **Step 2: Run red, implement page + l10n, `flutter gen-l10n`, run green** — `flutter test test/features/divelogs_sync test/l10n && flutter analyze`. +- [ ] **Step 3: Commit** — `feat: sync dive photos from the divelogs.de sync page` + +--- + +### Task 5: Verification sweep + +- [ ] **Step 1:** `dart format . && flutter analyze` — clean. +- [ ] **Step 2:** `flutter test test/core/services/divelogs test/features/divelogs_sync test/features/import_wizard test/features/universal_import/data/services` — all PASS. +- [ ] **Step 3:** Full suite in the background (unmasked exit code); apply the flake protocol from memory `flaky-backup-tests-full-suite` to any backup/setup failure. +- [ ] **Step 4:** Commit fixes if any. Do not push (user-triggered; branch carries PR #603). + +## Deferred (do NOT build now) + +- Certification scan pull/push (undocumented URLs; creation-only upload — revisit on Rainer's answer to open question 9). +- `DELETE /pictures` (create-only model). +- Video/media-store integration beyond the standard `importLocalFileForDive` path (the media-store upload enqueue fires automatically via `onMediaCreated`). + +## Open assumptions (confirm with Rainer, do not block) + +- `GET /pictures/{dive_id}` rows carry an absolute image URL under `url`/`link`/`href`/`imageurl` (rows without one are skipped and surfaced). +- Downloading picture bytes accepts the same bearer token. +- `POST /pictures/{dive_id}` accepts JPEG/PNG originals of typical camera size. From a5283966a5bc266928c1f8780e350975e4f44c19 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 12:40:21 -0400 Subject: [PATCH 30/35] feat: add divelogs.de picture endpoints with tolerant URL parsing --- .../divelogs/divelogs_api_client.dart | 93 +++++++++++++++++ .../services/divelogs/divelogs_models.dart | 31 ++++++ .../divelogs/divelogs_api_client_test.dart | 99 +++++++++++++++++++ .../divelogs/divelogs_models_test.dart | 29 ++++++ 4 files changed, 252 insertions(+) diff --git a/lib/core/services/divelogs/divelogs_api_client.dart b/lib/core/services/divelogs/divelogs_api_client.dart index fc845c25f8..ebcedcf49f 100644 --- a/lib/core/services/divelogs/divelogs_api_client.dart +++ b/lib/core/services/divelogs/divelogs_api_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:submersion/core/services/divelogs/divelogs_models.dart'; @@ -205,6 +206,98 @@ class DivelogsApiClient { } } + Future> getPictures(String diveId) async { + final response = await _get('/pictures/$diveId'); + final rows = _rows(_decode(response.body, '/pictures'), '/pictures', const [ + 'pictures', + ]); + return [ + for (final row in rows) + if (row is Map) + ...?_maybe(DivelogsPicture.fromJson(Map.from(row))), + ]; + } + + /// Fetches picture bytes from an absolute URL (NOT a /api path), reusing + /// the same bearer + 401-invalidate-retry-once contract. + Future downloadPictureBytes(Uri url) async { + var authRetried = false; + while (true) { + final token = await _getBearerToken(); + final http.Response response; + try { + response = await _http.get( + url, + headers: {'Authorization': 'Bearer $token'}, + ); + } on Exception { + throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + _onTokenRejected(); + if (!authRetried) { + authRetried = true; + continue; + } + throw const DivelogsApiException( + 401, + 'divelogs.de sign-in expired. Sign in again in Settings.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsApiException( + response.statusCode, + 'divelogs.de API error ${response.statusCode}', + ); + } + return response.bodyBytes; + } + } + + /// Uploads a picture to a dive via multipart form-data (field `imagefile`). + /// Same 401-retry-once semantics; the request is rebuilt per attempt. + Future postPicture( + String diveId, { + required List bytes, + required String filename, + }) async { + var authRetried = false; + while (true) { + final token = await _getBearerToken(); + final request = http.MultipartRequest( + 'POST', + _baseUri.replace(path: '${_baseUri.path}/pictures/$diveId'), + )..headers['Authorization'] = 'Bearer $token'; + request.files.add( + http.MultipartFile.fromBytes('imagefile', bytes, filename: filename), + ); + final http.Response response; + try { + response = await http.Response.fromStream(await _http.send(request)); + } on Exception { + throw const DivelogsApiException(0, 'Could not reach divelogs.de.'); + } + if (response.statusCode == 401) { + _onTokenRejected(); + if (!authRetried) { + authRetried = true; + continue; + } + throw const DivelogsApiException( + 401, + 'divelogs.de sign-in expired. Sign in again in Settings.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw DivelogsApiException( + response.statusCode, + 'divelogs.de API error ${response.statusCode}', + ); + } + return; + } + } + List _rows(Object? decoded, String endpoint, List listKeys) { if (decoded is List) return decoded; if (decoded is Map) { diff --git a/lib/core/services/divelogs/divelogs_models.dart b/lib/core/services/divelogs/divelogs_models.dart index 1c73dafea8..3f2ba5d1a6 100644 --- a/lib/core/services/divelogs/divelogs_models.dart +++ b/lib/core/services/divelogs/divelogs_models.dart @@ -314,3 +314,34 @@ class DivelogsCertification { ); } } + +/// One row of GET /pictures/{dive_id}. The response shape is undocumented, +/// so parsing is tolerant: [url] is the first key that resolves to an +/// absolute http(s) URI, and rows with only a bare filename keep their id +/// with a null url (the caller counts those as un-downloadable). +class DivelogsPicture { + final String? id; + final Uri? url; + + const DivelogsPicture({this.id, this.url}); + + static const _urlKeys = ['url', 'link', 'href', 'imageurl']; + + static DivelogsPicture? fromJson(Map json) { + final rawId = json['id'] ?? json['picture_id']; + Uri? url; + for (final key in _urlKeys) { + final candidate = _asNonEmptyString(json[key]); + if (candidate == null) continue; + final parsed = Uri.tryParse(candidate); + if (parsed != null && + (parsed.isScheme('http') || parsed.isScheme('https'))) { + url = parsed; + break; + } + } + final hasUrlKey = _urlKeys.any((k) => json[k] != null); + if (rawId == null && !hasUrlKey) return null; + return DivelogsPicture(id: rawId == null ? null : '$rawId', url: url); + } +} diff --git a/test/core/services/divelogs/divelogs_api_client_test.dart b/test/core/services/divelogs/divelogs_api_client_test.dart index 37890e4534..953060fb32 100644 --- a/test/core/services/divelogs/divelogs_api_client_test.dart +++ b/test/core/services/divelogs/divelogs_api_client_test.dart @@ -292,6 +292,105 @@ void main() { await api.postCertification(name: 'OWD', date: '2022-06-15'); expect(calls, 2); }); + + test('getPictures parses an array body', () async { + final api = client((req) async { + expect(req.url.path, '/api/pictures/4711'); + return http.Response( + jsonEncode([ + {'id': 1, 'url': 'https://divelogs.de/p/1.jpg'}, + {'id': 2, 'url': '2.jpg'}, + ]), + 200, + ); + }); + final pics = await api.getPictures('4711'); + expect(pics, hasLength(2)); + expect(pics[0].url, Uri.parse('https://divelogs.de/p/1.jpg')); + expect(pics[1].url, isNull); + }); + + test('getPictures tolerates a {pictures: [...]} wrapper', () async { + final api = client( + (req) async => http.Response( + jsonEncode({ + 'pictures': [ + {'id': 1, 'url': 'https://divelogs.de/p/1.jpg'}, + ], + }), + 200, + ), + ); + expect((await api.getPictures('9')).single.id, '1'); + }); + + test( + 'downloadPictureBytes sends bearer to the exact url, returns bytes', + () async { + late Uri requested; + final api = client((req) async { + requested = req.url; + expect(req.headers['Authorization'], 'Bearer t1'); + return http.Response.bytes([1, 2, 3, 4], 200); + }); + final bytes = await api.downloadPictureBytes( + Uri.parse('https://cdn.divelogs.de/p/5.jpg'), + ); + expect(requested, Uri.parse('https://cdn.divelogs.de/p/5.jpg')); + expect(bytes, [1, 2, 3, 4]); + }, + ); + + test('downloadPictureBytes retries once on 401', () async { + var calls = 0; + final api = client((req) async { + calls++; + if (req.headers['Authorization'] == 'Bearer t1') { + return http.Response('', 401); + } + return http.Response.bytes([9], 200); + }, tokens: ['t1', 't2']); + final bytes = await api.downloadPictureBytes( + Uri.parse('https://cdn.divelogs.de/p/5.jpg'), + ); + expect(bytes, [9]); + expect(calls, 2); + }); + + test('postPicture sends a multipart imagefile part with filename', () async { + late http.MultipartRequest captured; + final api = DivelogsApiClient( + getBearerToken: () async => 't1', + onTokenRejected: () {}, + httpClient: _CapturingClient( + (req) => captured = req as http.MultipartRequest, + ), + ); + await api.postPicture('4711', bytes: [1, 2, 3], filename: 'photo.jpg'); + expect(captured.method, 'POST'); + expect(captured.url.path, '/api/pictures/4711'); + expect(captured.headers['Authorization'], 'Bearer t1'); + expect(captured.files, hasLength(1)); + expect(captured.files.single.field, 'imagefile'); + expect(captured.files.single.filename, 'photo.jpg'); + }); + + test('postPicture retries once on 401', () async { + var calls = 0; + final tokens = ['t1', 't2']; + final api = DivelogsApiClient( + getBearerToken: () async => + tokens.length > 1 ? tokens.removeAt(0) : tokens.first, + onTokenRejected: () {}, + httpClient: _CapturingClient( + (req) => calls++, + statusFor: (req) => + req.headers['Authorization'] == 'Bearer t1' ? 401 : 200, + ), + ); + await api.postPicture('9', bytes: [1], filename: 'x.jpg'); + expect(calls, 2); + }); } /// Minimal client that captures BaseRequests (MockClient materializes diff --git a/test/core/services/divelogs/divelogs_models_test.dart b/test/core/services/divelogs/divelogs_models_test.dart index 94264b47c9..f1c993c069 100644 --- a/test/core/services/divelogs/divelogs_models_test.dart +++ b/test/core/services/divelogs/divelogs_models_test.dart @@ -181,6 +181,35 @@ void main() { }); }); + group('DivelogsPicture', () { + test('picks the first absolute http(s) url from known keys', () { + final pic = DivelogsPicture.fromJson({ + 'id': 5, + 'url': 'https://divelogs.de/pics/5.jpg', + })!; + expect(pic.id, '5'); + expect(pic.url, Uri.parse('https://divelogs.de/pics/5.jpg')); + }); + + test('falls back through link/href/imageurl', () { + final pic = DivelogsPicture.fromJson({ + 'id': 6, + 'link': 'http://divelogs.de/pics/6.jpg', + })!; + expect(pic.url, Uri.parse('http://divelogs.de/pics/6.jpg')); + }); + + test('keeps the row but null url for a bare filename', () { + final pic = DivelogsPicture.fromJson({'id': 7, 'url': '7.jpg'})!; + expect(pic.id, '7'); + expect(pic.url, isNull); + }); + + test('returns null when neither id nor any url key is present', () { + expect(DivelogsPicture.fromJson({'foo': 'bar'}), isNull); + }); + }); + test('DivelogsDive parses gearitems as string ids', () { final dive = DivelogsDive.fromJson({ 'id': 1, From a8c8b2be10ba75485a120cbe1484ac1ecea50c87 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 12:41:26 -0400 Subject: [PATCH 31/35] feat: expose matched remote-local dive pairs from the sync planner --- .../services/divelogs_sync_planner.dart | 29 +++++++++++++++++-- .../services/divelogs_sync_planner_test.dart | 3 ++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart b/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart index 80aa16f3a0..9a5e7a692a 100644 --- a/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart +++ b/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart @@ -2,15 +2,31 @@ import 'package:submersion/core/services/divelogs/divelogs_models.dart'; import 'package:submersion/features/dive_import/domain/services/dive_matcher.dart'; import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; +/// A remote dive matched to a local dive (same physical dive). Photo sync +/// (Phase 4) walks these pairs. +class DivelogsMatchedDive { + final String remoteId; + final String localDiveId; + final DateTime localTime; + + const DivelogsMatchedDive({ + required this.remoteId, + required this.localDiveId, + required this.localTime, + }); +} + /// Result of comparing the remote divelist with local dive summaries. class DivelogsSyncPlan { final List pullCandidates; final List pushCandidates; + final List matchedPairs; final int matchedCount; const DivelogsSyncPlan({ required this.pullCandidates, required this.pushCandidates, + required this.matchedPairs, required this.matchedCount, }); } @@ -35,7 +51,7 @@ class DivelogsSyncPlanner { ..sort((a, b) => a.dateTime.compareTo(b.dateTime)); final unmatchedLocal = [...local]; final pull = []; - var matched = 0; + final matchedPairs = []; for (final entry in sortedRemote) { DiveSummary? best; @@ -49,7 +65,13 @@ class DivelogsSyncPlanner { } if (best != null) { unmatchedLocal.remove(best); - matched++; + matchedPairs.add( + DivelogsMatchedDive( + remoteId: entry.id, + localDiveId: best.id, + localTime: best.entryTime ?? best.dateTime, + ), + ); } else { pull.add(entry); } @@ -58,7 +80,8 @@ class DivelogsSyncPlanner { return DivelogsSyncPlan( pullCandidates: pull, pushCandidates: unmatchedLocal, - matchedCount: matched, + matchedPairs: matchedPairs, + matchedCount: matchedPairs.length, ); } diff --git a/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart b/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart index 96e4c6c92a..1614134b6a 100644 --- a/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart +++ b/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart @@ -45,6 +45,9 @@ void main() { expect(plan.pullCandidates, isEmpty); expect(plan.pushCandidates, isEmpty); expect(plan.matchedCount, 1); + expect(plan.matchedPairs.single.remoteId, 'r1'); + expect(plan.matchedPairs.single.localDiveId, 'l1'); + expect(plan.matchedPairs.single.localTime, t0); }); test('remote-only dives are pull candidates, local-only are push', () { From d0badbe434c044414a6c6534dd665a2cd605913b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 12:43:44 -0400 Subject: [PATCH 32/35] feat: add create-only divelogs.de photo sync with hash dedup --- .../services/divelogs_photo_sync_service.dart | 148 +++++++++++ .../divelogs_photo_sync_service_test.dart | 233 ++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 lib/features/divelogs_sync/domain/services/divelogs_photo_sync_service.dart create mode 100644 test/features/divelogs_sync/domain/services/divelogs_photo_sync_service_test.dart diff --git a/lib/features/divelogs_sync/domain/services/divelogs_photo_sync_service.dart b/lib/features/divelogs_sync/domain/services/divelogs_photo_sync_service.dart new file mode 100644 index 0000000000..16bf97e75b --- /dev/null +++ b/lib/features/divelogs_sync/domain/services/divelogs_photo_sync_service.dart @@ -0,0 +1,148 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/core/services/divelogs/divelogs_models.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_sync_planner.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +class PhotoSyncResult { + final int pulled; + final int pulledDuplicates; + final int skippedNoUrl; + final int pushed; + final String? error; + + const PhotoSyncResult({ + this.pulled = 0, + this.pulledDuplicates = 0, + this.skippedNoUrl = 0, + this.pushed = 0, + this.error, + }); + + bool get failed => error != null; +} + +/// Create-only photo sync for matched dives (spec Phase 4). +/// +/// Pull dedups by SHA-256 of the downloaded bytes against the dive's local +/// photos (there is no remote hash). Push is guarded by the only safe +/// create-only signal the API offers: a dive that has ZERO remote pictures. +/// Dependencies are function-injected so the service stays free of +/// repository/resolver plumbing and is fully unit-testable. +/// +/// Certification scans are intentionally excluded: their download URLs are +/// undocumented and the API only accepts scans at certification creation +/// (revisit when Rainer answers). +class DivelogsPhotoSyncService { + DivelogsPhotoSyncService({ + required DivelogsApiClient api, + required Future> Function(String diveId) getLocalMedia, + required Future Function(MediaItem item) resolveLocalBytes, + required Future Function({ + required Uint8List bytes, + required String filename, + required String diveId, + required DateTime takenAt, + }) + attachToDive, + }) : _api = api, + _getLocalMedia = getLocalMedia, + _resolveLocalBytes = resolveLocalBytes, + _attachToDive = attachToDive; + + final DivelogsApiClient _api; + final Future> Function(String diveId) _getLocalMedia; + final Future Function(MediaItem item) _resolveLocalBytes; + final Future Function({ + required Uint8List bytes, + required String filename, + required String diveId, + required DateTime takenAt, + }) + _attachToDive; + + Future sync( + List pairs, { + void Function(int done, int total)? onProgress, + }) async { + var pulled = 0; + var duplicates = 0; + var skippedNoUrl = 0; + var pushed = 0; + + try { + for (var i = 0; i < pairs.length; i++) { + final pair = pairs[i]; + final remote = await _api.getPictures(pair.remoteId); + final withUrl = remote.where((p) => p.url != null).toList(); + skippedNoUrl += remote.length - withUrl.length; + + final localPhotos = (await _getLocalMedia( + pair.localDiveId, + )).where((m) => m.mediaType == MediaType.photo).toList(); + final localHashes = {}; + for (final item in localPhotos) { + final bytes = await _resolveLocalBytes(item); + if (bytes != null) localHashes.add(sha256.convert(bytes).toString()); + } + + // Pull. + for (final picture in withUrl) { + final bytes = await _api.downloadPictureBytes(picture.url!); + final hash = sha256.convert(bytes).toString(); + if (localHashes.contains(hash)) { + duplicates++; + continue; + } + await _attachToDive( + bytes: bytes, + filename: _filenameFor(picture), + diveId: pair.localDiveId, + takenAt: pair.localTime, + ); + localHashes.add(hash); + pulled++; + } + + // Push: only for dives with no remote pictures at all. + if (remote.isEmpty && localPhotos.isNotEmpty) { + for (final item in localPhotos) { + final bytes = await _resolveLocalBytes(item); + if (bytes == null) continue; + await _api.postPicture( + pair.remoteId, + bytes: bytes, + filename: item.originalFilename ?? '${item.id}.jpg', + ); + pushed++; + } + } + + onProgress?.call(i + 1, pairs.length); + } + } on DivelogsApiException catch (e) { + return PhotoSyncResult( + pulled: pulled, + pulledDuplicates: duplicates, + skippedNoUrl: skippedNoUrl, + pushed: pushed, + error: e.message, + ); + } + + return PhotoSyncResult( + pulled: pulled, + pulledDuplicates: duplicates, + skippedNoUrl: skippedNoUrl, + pushed: pushed, + ); + } + + String _filenameFor(DivelogsPicture picture) { + final segments = picture.url!.pathSegments; + if (segments.isNotEmpty && segments.last.isNotEmpty) return segments.last; + return 'divelogs_${picture.id ?? 'photo'}.jpg'; + } +} diff --git a/test/features/divelogs_sync/domain/services/divelogs_photo_sync_service_test.dart b/test/features/divelogs_sync/domain/services/divelogs_photo_sync_service_test.dart new file mode 100644 index 0000000000..a6ac92b22a --- /dev/null +++ b/test/features/divelogs_sync/domain/services/divelogs_photo_sync_service_test.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/services/divelogs/divelogs_api_client.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_photo_sync_service.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_sync_planner.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; + +void main() { + final t0 = DateTime.utc(2022, 9, 3, 10); + + DivelogsMatchedDive pair(String remote, String local) => + DivelogsMatchedDive(remoteId: remote, localDiveId: local, localTime: t0); + + MediaItem photo(String id, {String? filename}) => MediaItem( + id: id, + diveId: 'l1', + mediaType: MediaType.photo, + originalFilename: filename, + takenAt: t0, + createdAt: t0, + updatedAt: t0, + ); + + DivelogsApiClient api(Future Function(http.Request) handler) => + DivelogsApiClient( + getBearerToken: () async => 't', + onTokenRejected: () {}, + httpClient: MockClient(handler), + ); + + /// Records the calls the page-level wiring would make. + ({ + List<({Uint8List bytes, String filename, String diveId, DateTime takenAt})> + attached, + DivelogsPhotoSyncService service, + }) + build( + DivelogsApiClient client, { + Map> localByDive = const {}, + Map localBytes = const {}, + }) { + final attached = + < + ({Uint8List bytes, String filename, String diveId, DateTime takenAt}) + >[]; + final service = DivelogsPhotoSyncService( + api: client, + getLocalMedia: (diveId) async => localByDive[diveId] ?? const [], + resolveLocalBytes: (item) async => localBytes[item.id], + attachToDive: + ({ + required bytes, + required filename, + required diveId, + required takenAt, + }) async { + attached.add(( + bytes: bytes, + filename: filename, + diveId: diveId, + takenAt: takenAt, + )); + }, + ); + return (attached: attached, service: service); + } + + test( + 'pulls a new remote picture and attaches it (filename from url)', + () async { + final client = api((req) async { + if (req.url.path == '/api/pictures/r1') { + return http.Response( + jsonEncode([ + {'id': 1, 'url': 'https://cdn.divelogs.de/p/1.jpg'}, + ]), + 200, + ); + } + if (req.url.host == 'cdn.divelogs.de') { + return http.Response.bytes([1, 2, 3], 200); + } + fail('unexpected ${req.method} ${req.url}'); + }); + final h = build(client); + final result = await h.service.sync([pair('r1', 'l1')]); + + expect(result.pulled, 1); + expect(result.pulledDuplicates, 0); + expect(h.attached.single.filename, '1.jpg'); + expect(h.attached.single.bytes, [1, 2, 3]); + expect(h.attached.single.diveId, 'l1'); + expect(h.attached.single.takenAt, t0); + }, + ); + + test( + 'a remote picture matching a local hash is a duplicate, not attached', + () async { + final client = api((req) async { + if (req.url.path == '/api/pictures/r1') { + return http.Response( + jsonEncode([ + {'id': 1, 'url': 'https://cdn.divelogs.de/p/1.jpg'}, + ]), + 200, + ); + } + return http.Response.bytes([7, 7, 7], 200); + }); + final h = build( + client, + localByDive: { + 'l1': [photo('m1')], + }, + localBytes: { + 'm1': Uint8List.fromList([7, 7, 7]), + }, + ); + final result = await h.service.sync([pair('r1', 'l1')]); + + expect(result.pulled, 0); + expect(result.pulledDuplicates, 1); + expect(h.attached, isEmpty); + }, + ); + + test('remote rows without a usable url are counted as skipped', () async { + final client = api((req) async { + if (req.url.path == '/api/pictures/r1') { + return http.Response( + jsonEncode([ + {'id': 1, 'url': '1.jpg'}, + ]), + 200, + ); + } + fail('no download expected for a bare filename'); + }); + final h = build(client); + final result = await h.service.sync([pair('r1', 'l1')]); + + expect(result.skippedNoUrl, 1); + expect(result.pulled, 0); + expect(h.attached, isEmpty); + }); + + test('pushes local photos only when the remote list is empty', () async { + var posted = 0; + late http.MultipartRequest capturedPush; + final client = DivelogsApiClient( + getBearerToken: () async => 't', + onTokenRejected: () {}, + httpClient: _CapturingClient( + (req) { + if (req.method == 'POST') { + posted++; + capturedPush = req as http.MultipartRequest; + } + }, + bodyForGet: (req) { + // r1 has a remote picture, r2 has none. + return req.url.path == '/api/pictures/r1' + ? jsonEncode([ + {'id': 9, 'url': 'https://cdn.divelogs.de/p/9.jpg'}, + ]) + : jsonEncode([]); + }, + ), + ); + final h = build( + client, + localByDive: { + 'l1': [photo('m1', filename: 'a.jpg')], + 'l2': [photo('m2', filename: 'b.jpg')], + }, + localBytes: { + 'm1': Uint8List.fromList([1]), + 'm2': Uint8List.fromList([2]), + }, + ); + // r1 (has remote pics, so pull downloads them) and r2 (empty, so push). + final result = await h.service.sync([pair('r1', 'l1'), pair('r2', 'l2')]); + + expect(posted, 1, reason: 'only l2 pushes (r2 has no remote pictures)'); + expect(capturedPush.files.single.field, 'imagefile'); + expect(capturedPush.files.single.filename, 'b.jpg'); + expect(result.pushed, 1); + }); + + test('a 500 mid-run stops and reports partial counts', () async { + final client = api((req) async { + if (req.url.path == '/api/pictures/r1') { + return http.Response( + jsonEncode([ + {'id': 1, 'url': 'https://cdn.divelogs.de/p/1.jpg'}, + ]), + 200, + ); + } + if (req.url.host == 'cdn.divelogs.de') { + return http.Response('', 500); + } + fail('unexpected ${req.url}'); + }); + final h = build(client); + final result = await h.service.sync([pair('r1', 'l1')]); + + expect(result.failed, isTrue); + expect(result.error, contains('500')); + expect(result.pulled, 0); + }); +} + +/// Captures POSTs and serves a JSON body for GETs (MockClient materializes +/// multipart bodies, so we need a BaseClient to inspect the file part). +class _CapturingClient extends http.BaseClient { + _CapturingClient(this.onRequest, {required this.bodyForGet}); + + final void Function(http.BaseRequest) onRequest; + final String Function(http.BaseRequest) bodyForGet; + + @override + Future send(http.BaseRequest request) async { + onRequest(request); + final body = request.method == 'GET' ? bodyForGet(request) : '{}'; + return http.StreamedResponse(Stream.value(utf8.encode(body)), 200); + } +} From ed6fcf2619fb6c0b06c0efc77506ac01a32a819e Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 17 Jul 2026 12:48:17 -0400 Subject: [PATCH 33/35] feat: sync dive photos from the divelogs.de sync page --- .../pages/divelogs_sync_page.dart | 125 ++++++++++++++++++ lib/l10n/arb/app_ar.arb | 7 + lib/l10n/arb/app_de.arb | 7 + lib/l10n/arb/app_en.arb | 12 ++ lib/l10n/arb/app_es.arb | 7 + lib/l10n/arb/app_fr.arb | 7 + lib/l10n/arb/app_he.arb | 7 + lib/l10n/arb/app_hu.arb | 7 + lib/l10n/arb/app_it.arb | 7 + lib/l10n/arb/app_localizations.dart | 42 ++++++ lib/l10n/arb/app_localizations_ar.dart | 32 +++++ lib/l10n/arb/app_localizations_de.dart | 32 +++++ lib/l10n/arb/app_localizations_en.dart | 31 +++++ lib/l10n/arb/app_localizations_es.dart | 32 +++++ lib/l10n/arb/app_localizations_fr.dart | 32 +++++ lib/l10n/arb/app_localizations_he.dart | 31 +++++ lib/l10n/arb/app_localizations_hu.dart | 32 +++++ lib/l10n/arb/app_localizations_it.dart | 32 +++++ lib/l10n/arb/app_localizations_nl.dart | 32 +++++ lib/l10n/arb/app_localizations_pt.dart | 32 +++++ lib/l10n/arb/app_localizations_zh.dart | 31 +++++ lib/l10n/arb/app_nl.arb | 7 + lib/l10n/arb/app_pt.arb | 7 + lib/l10n/arb/app_zh.arb | 7 + .../pages/divelogs_sync_page_test.dart | 38 ++++++ 25 files changed, 636 insertions(+) diff --git a/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart index 8b9385526a..30e56e1b4a 100644 --- a/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart +++ b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart @@ -1,6 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:path/path.dart' as p; import 'package:submersion/core/providers/account_providers.dart'; import 'package:submersion/core/services/accounts/account_kind.dart'; import 'package:submersion/core/services/accounts/account_provider_adapter.dart'; @@ -12,12 +15,17 @@ import 'package:submersion/features/certifications/presentation/providers/certif import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; import 'package:submersion/features/divelogs_sync/domain/services/divelogs_gear_cert_push_service.dart'; +import 'package:submersion/features/divelogs_sync/domain/services/divelogs_photo_sync_service.dart'; import 'package:submersion/features/divelogs_sync/domain/services/divelogs_push_service.dart'; import 'package:submersion/features/divelogs_sync/domain/services/divelogs_sync_planner.dart'; import 'package:submersion/features/divelogs_sync/domain/services/gear_cert_sync_planner.dart'; import 'package:submersion/features/equipment/presentation/providers/equipment_providers.dart'; import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; import 'package:submersion/features/import_wizard/data/adapters/divelogs_adapter.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.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/photo_picker_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; enum _PagePhase { @@ -56,6 +64,11 @@ class _DivelogsSyncPageState extends ConsumerState { String? _gearCertError; GearCertPushResult? _lastGearCertResult; bool _pushingGearCerts = false; + List _matchedPairs = const []; + PhotoSyncResult? _lastPhotoResult; + bool _syncingPhotos = false; + int _photoSyncDone = 0; + int _photoSyncTotal = 0; @override void initState() { @@ -160,6 +173,7 @@ class _DivelogsSyncPageState extends ConsumerState { setState(() { _plan = plan; _selectedPushIds = plan.pushCandidates.map((s) => s.id).toSet(); + _matchedPairs = plan.matchedPairs; _gearCertPlan = gearCertPlan; _gearCertError = gearCertError; _geartypes = geartypes; @@ -229,6 +243,63 @@ class _DivelogsSyncPageState extends ConsumerState { await _compare(); } + Future _syncPhotos() async { + final account = _account; + if (account == null || _matchedPairs.isEmpty) return; + setState(() { + _syncingPhotos = true; + _photoSyncDone = 0; + _photoSyncTotal = _matchedPairs.length; + }); + final mediaRepo = ref.read(mediaRepositoryProvider); + final resolvers = ref.read(mediaSourceResolverRegistryProvider); + final importService = ref.read(mediaImportServiceProvider); + final service = DivelogsPhotoSyncService( + api: _api(account), + getLocalMedia: mediaRepo.getMediaForDive, + resolveLocalBytes: (item) async { + final data = await resolvers.resolverFor(item.sourceType).resolve(item); + return switch (data) { + FileData(:final file) => await file.readAsBytes(), + BytesData(:final bytes) => bytes, + _ => null, + }; + }, + attachToDive: + ({ + required bytes, + required filename, + required diveId, + required takenAt, + }) async { + final dir = await Directory.systemTemp.createTemp('divelogs_photo'); + final file = File(p.join(dir.path, p.basename(filename))); + await file.writeAsBytes(bytes); + await importService.importLocalFileForDive( + sourceFile: file, + diveId: diveId, + takenAt: takenAt, + ); + }, + ); + final result = await service.sync( + _matchedPairs, + onProgress: (done, total) { + if (!mounted) return; + setState(() { + _photoSyncDone = done; + _photoSyncTotal = total; + }); + }, + ); + if (!mounted) return; + // Photos do not change the dive diff, so no re-compare. + setState(() { + _lastPhotoResult = result; + _syncingPhotos = false; + }); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -431,10 +502,64 @@ class _DivelogsSyncPageState extends ConsumerState { ], const SizedBox(height: 16), ..._buildGearCertSection(context), + const SizedBox(height: 16), + ..._buildPhotoSection(context), ], ); } + List _buildPhotoSection(BuildContext context) { + final l10n = context.l10n; + final result = _lastPhotoResult; + return [ + Text( + l10n.divelogsSync_photosHeader, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (_syncingPhotos) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: _photoSyncTotal == 0 + ? null + : _photoSyncDone / _photoSyncTotal, + ), + const SizedBox(height: 8), + Text(l10n.divelogsSync_photosSyncing), + ], + ), + ), + if (result != null) ...[ + Text( + result.failed + ? l10n.divelogsSync_photosFailed(result.error!) + : l10n.divelogsSync_photosDone(result.pulled, result.pushed), + ), + if (result.pulledDuplicates > 0) + Text( + l10n.divelogsSync_photosDuplicates(result.pulledDuplicates), + style: Theme.of(context).textTheme.bodySmall, + ), + if (result.skippedNoUrl > 0) + Text( + l10n.divelogsSync_photosNoUrl(result.skippedNoUrl), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + ], + FilledButton.tonal( + onPressed: (_matchedPairs.isEmpty || _syncingPhotos) + ? null + : _syncPhotos, + child: Text(l10n.divelogsSync_photosButton(_matchedPairs.length)), + ), + ]; + } + List _buildGearCertSection(BuildContext context) { final l10n = context.l10n; final plan = _gearCertPlan; diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index be2c63f38b..f94534f956 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "الصور", + "divelogsSync_photosButton": "مزامنة صور {count} غطسات متطابقة", + "divelogsSync_photosSyncing": "جارٍ مزامنة الصور مع divelogs.de...", + "divelogsSync_photosDone": "تم سحب {pulled} صور ودفع {pushed}.", + "divelogsSync_photosDuplicates": "{count} صور كانت موجودة بالفعل (تم التعرف عليها بالمحتوى).", + "divelogsSync_photosNoUrl": "{count} صور بعيدة لم يكن لها رابط تنزيل وتم تخطيها.", + "divelogsSync_photosFailed": "توقفت مزامنة الصور: {error}", "divelogsSync_gearCertHeader": "المعدات والشهادات", "divelogsSync_gearCertMatched": "{gear} قطع معدات و{certs} شهادات متزامنة بالفعل", "divelogsSync_gearCertPush": "دفع: {gear} قطع معدات، {certs} شهادات", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 2aad3544b9..4975863267 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "Fotos", + "divelogsSync_photosButton": "Fotos für {count} zugeordnete Tauchgänge synchronisieren", + "divelogsSync_photosSyncing": "Fotos werden mit divelogs.de synchronisiert...", + "divelogsSync_photosDone": "{pulled} Fotos geladen, {pushed} gesendet.", + "divelogsSync_photosDuplicates": "{count} Fotos waren bereits vorhanden (inhaltlich erkannt).", + "divelogsSync_photosNoUrl": "{count} entfernte Bilder hatten keinen ladbaren Link und wurden übersprungen.", + "divelogsSync_photosFailed": "Fotosynchronisierung gestoppt: {error}", "divelogsSync_gearCertHeader": "Ausrüstung & Zertifizierungen", "divelogsSync_gearCertMatched": "{gear} Ausrüstungsteile und {certs} Zertifizierungen bereits synchron", "divelogsSync_gearCertPush": "Senden: {gear} Ausrüstungsteile, {certs} Zertifizierungen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1dd53a3455..14309638da 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,16 @@ { + "divelogsSync_photosHeader": "Photos", + "divelogsSync_photosButton": "Sync photos for {count} matched dives", + "@divelogsSync_photosButton": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_photosSyncing": "Syncing photos with divelogs.de...", + "divelogsSync_photosDone": "Pulled {pulled} photos, pushed {pushed}.", + "@divelogsSync_photosDone": {"placeholders": {"pulled": {"type": "int"}, "pushed": {"type": "int"}}}, + "divelogsSync_photosDuplicates": "{count} photos were already present (matched by content).", + "@divelogsSync_photosDuplicates": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_photosNoUrl": "{count} remote pictures had no downloadable link and were skipped.", + "@divelogsSync_photosNoUrl": {"placeholders": {"count": {"type": "int"}}}, + "divelogsSync_photosFailed": "Photo sync stopped: {error}", + "@divelogsSync_photosFailed": {"placeholders": {"error": {"type": "String"}}}, "divelogsSync_gearCertHeader": "Gear & certifications", "divelogsSync_gearCertMatched": "{gear} gear items and {certs} certifications already in sync", "@divelogsSync_gearCertMatched": {"placeholders": {"gear": {"type": "int"}, "certs": {"type": "int"}}}, diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index ee935ab68e..0041a944fb 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "Fotos", + "divelogsSync_photosButton": "Sincronizar fotos de {count} inmersiones coincidentes", + "divelogsSync_photosSyncing": "Sincronizando fotos con divelogs.de...", + "divelogsSync_photosDone": "Se descargaron {pulled} fotos y se subieron {pushed}.", + "divelogsSync_photosDuplicates": "{count} fotos ya estaban presentes (coincidencia por contenido).", + "divelogsSync_photosNoUrl": "{count} imágenes remotas no tenían enlace de descarga y se omitieron.", + "divelogsSync_photosFailed": "La sincronización de fotos se detuvo: {error}", "divelogsSync_gearCertHeader": "Equipo y certificaciones", "divelogsSync_gearCertMatched": "{gear} equipos y {certs} certificaciones ya sincronizados", "divelogsSync_gearCertPush": "Subir: {gear} equipos, {certs} certificaciones", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index e26b720112..5762d087f7 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "Photos", + "divelogsSync_photosButton": "Synchroniser les photos de {count} plongées associées", + "divelogsSync_photosSyncing": "Synchronisation des photos avec divelogs.de...", + "divelogsSync_photosDone": "{pulled} photos récupérées, {pushed} envoyées.", + "divelogsSync_photosDuplicates": "{count} photos étaient déjà présentes (reconnues par contenu).", + "divelogsSync_photosNoUrl": "{count} images distantes n'avaient aucun lien téléchargeable et ont été ignorées.", + "divelogsSync_photosFailed": "Synchronisation des photos arrêtée : {error}", "divelogsSync_gearCertHeader": "Équipement et certifications", "divelogsSync_gearCertMatched": "{gear} équipements et {certs} certifications déjà synchronisés", "divelogsSync_gearCertPush": "Envoyer : {gear} équipements, {certs} certifications", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index c6787d7c8d..09fdc0a8a9 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "תמונות", + "divelogsSync_photosButton": "סנכרן תמונות עבור {count} צלילות תואמות", + "divelogsSync_photosSyncing": "מסנכרן תמונות עם divelogs.de...", + "divelogsSync_photosDone": "{pulled} תמונות נמשכו, {pushed} נדחפו.", + "divelogsSync_photosDuplicates": "{count} תמונות כבר היו קיימות (זוהו לפי תוכן).", + "divelogsSync_photosNoUrl": "ל-{count} תמונות מרוחקות לא היה קישור להורדה והן דולגו.", + "divelogsSync_photosFailed": "סנכרון התמונות נעצר: {error}", "divelogsSync_gearCertHeader": "ציוד והסמכות", "divelogsSync_gearCertMatched": "{gear} פריטי ציוד ו-{certs} הסמכות כבר מסונכרנים", "divelogsSync_gearCertPush": "דחיפה: {gear} פריטי ציוד, {certs} הסמכות", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index e205f1478a..f5b89b2946 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "Fényképek", + "divelogsSync_photosButton": "Fényképek szinkronizálása {count} párosított merüléshez", + "divelogsSync_photosSyncing": "Fényképek szinkronizálása a divelogs.de-vel...", + "divelogsSync_photosDone": "{pulled} fénykép letöltve, {pushed} feltöltve.", + "divelogsSync_photosDuplicates": "{count} fénykép már megvolt (tartalom alapján felismerve).", + "divelogsSync_photosNoUrl": "{count} távoli képnek nem volt letölthető hivatkozása, ezért kimaradtak.", + "divelogsSync_photosFailed": "A fényképszinkronizálás leállt: {error}", "divelogsSync_gearCertHeader": "Felszerelés és minősítések", "divelogsSync_gearCertMatched": "{gear} felszerelés és {certs} minősítés már szinkronban", "divelogsSync_gearCertPush": "Feltöltés: {gear} felszerelés, {certs} minősítés", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index f4559831e5..2b1e665bc7 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "Foto", + "divelogsSync_photosButton": "Sincronizza le foto di {count} immersioni corrispondenti", + "divelogsSync_photosSyncing": "Sincronizzazione foto con divelogs.de...", + "divelogsSync_photosDone": "{pulled} foto scaricate, {pushed} caricate.", + "divelogsSync_photosDuplicates": "{count} foto erano già presenti (riconosciute dal contenuto).", + "divelogsSync_photosNoUrl": "{count} immagini remote non avevano un link scaricabile e sono state ignorate.", + "divelogsSync_photosFailed": "Sincronizzazione foto interrotta: {error}", "divelogsSync_gearCertHeader": "Attrezzatura e certificazioni", "divelogsSync_gearCertMatched": "{gear} attrezzature e {certs} certificazioni già sincronizzate", "divelogsSync_gearCertPush": "Carica: {gear} attrezzature, {certs} certificazioni", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5a29c740ab..243b34168d 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -116,6 +116,48 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @divelogsSync_photosHeader. + /// + /// In en, this message translates to: + /// **'Photos'** + String get divelogsSync_photosHeader; + + /// No description provided for @divelogsSync_photosButton. + /// + /// In en, this message translates to: + /// **'Sync photos for {count} matched dives'** + String divelogsSync_photosButton(int count); + + /// No description provided for @divelogsSync_photosSyncing. + /// + /// In en, this message translates to: + /// **'Syncing photos with divelogs.de...'** + String get divelogsSync_photosSyncing; + + /// No description provided for @divelogsSync_photosDone. + /// + /// In en, this message translates to: + /// **'Pulled {pulled} photos, pushed {pushed}.'** + String divelogsSync_photosDone(int pulled, int pushed); + + /// No description provided for @divelogsSync_photosDuplicates. + /// + /// In en, this message translates to: + /// **'{count} photos were already present (matched by content).'** + String divelogsSync_photosDuplicates(int count); + + /// No description provided for @divelogsSync_photosNoUrl. + /// + /// In en, this message translates to: + /// **'{count} remote pictures had no downloadable link and were skipped.'** + String divelogsSync_photosNoUrl(int count); + + /// No description provided for @divelogsSync_photosFailed. + /// + /// In en, this message translates to: + /// **'Photo sync stopped: {error}'** + String divelogsSync_photosFailed(String error); + /// No description provided for @divelogsSync_gearCertHeader. /// /// 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 45d4da5a37..b35752b6e4 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get divelogsSync_photosHeader => 'الصور'; + + @override + String divelogsSync_photosButton(int count) { + return 'مزامنة صور $count غطسات متطابقة'; + } + + @override + String get divelogsSync_photosSyncing => + 'جارٍ مزامنة الصور مع divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return 'تم سحب $pulled صور ودفع $pushed.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count صور كانت موجودة بالفعل (تم التعرف عليها بالمحتوى).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count صور بعيدة لم يكن لها رابط تنزيل وتم تخطيها.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'توقفت مزامنة الصور: $error'; + } + @override String get divelogsSync_gearCertHeader => 'المعدات والشهادات'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 47859cc9a6..60bf3db352 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Fotos'; + + @override + String divelogsSync_photosButton(int count) { + return 'Fotos für $count zugeordnete Tauchgänge synchronisieren'; + } + + @override + String get divelogsSync_photosSyncing => + 'Fotos werden mit divelogs.de synchronisiert...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '$pulled Fotos geladen, $pushed gesendet.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count Fotos waren bereits vorhanden (inhaltlich erkannt).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count entfernte Bilder hatten keinen ladbaren Link und wurden übersprungen.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'Fotosynchronisierung gestoppt: $error'; + } + @override String get divelogsSync_gearCertHeader => 'Ausrüstung & Zertifizierungen'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 4694a1bfa7..68c7a973df 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8,6 +8,37 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Photos'; + + @override + String divelogsSync_photosButton(int count) { + return 'Sync photos for $count matched dives'; + } + + @override + String get divelogsSync_photosSyncing => 'Syncing photos with divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return 'Pulled $pulled photos, pushed $pushed.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count photos were already present (matched by content).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count remote pictures had no downloadable link and were skipped.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'Photo sync stopped: $error'; + } + @override String get divelogsSync_gearCertHeader => 'Gear & certifications'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 8b273d4bf0..09255b69c4 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Fotos'; + + @override + String divelogsSync_photosButton(int count) { + return 'Sincronizar fotos de $count inmersiones coincidentes'; + } + + @override + String get divelogsSync_photosSyncing => + 'Sincronizando fotos con divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return 'Se descargaron $pulled fotos y se subieron $pushed.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count fotos ya estaban presentes (coincidencia por contenido).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count imágenes remotas no tenían enlace de descarga y se omitieron.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'La sincronización de fotos se detuvo: $error'; + } + @override String get divelogsSync_gearCertHeader => 'Equipo y certificaciones'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 725bedb36c..4183d0df5d 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Photos'; + + @override + String divelogsSync_photosButton(int count) { + return 'Synchroniser les photos de $count plongées associées'; + } + + @override + String get divelogsSync_photosSyncing => + 'Synchronisation des photos avec divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '$pulled photos récupérées, $pushed envoyées.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count photos étaient déjà présentes (reconnues par contenu).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count images distantes n\'avaient aucun lien téléchargeable et ont été ignorées.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'Synchronisation des photos arrêtée : $error'; + } + @override String get divelogsSync_gearCertHeader => 'Équipement et certifications'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 9e1848dc08..130717becd 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8,6 +8,37 @@ import 'app_localizations.dart'; class AppLocalizationsHe extends AppLocalizations { AppLocalizationsHe([String locale = 'he']) : super(locale); + @override + String get divelogsSync_photosHeader => 'תמונות'; + + @override + String divelogsSync_photosButton(int count) { + return 'סנכרן תמונות עבור $count צלילות תואמות'; + } + + @override + String get divelogsSync_photosSyncing => 'מסנכרן תמונות עם divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '$pulled תמונות נמשכו, $pushed נדחפו.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count תמונות כבר היו קיימות (זוהו לפי תוכן).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return 'ל-$count תמונות מרוחקות לא היה קישור להורדה והן דולגו.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'סנכרון התמונות נעצר: $error'; + } + @override String get divelogsSync_gearCertHeader => 'ציוד והסמכות'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 45010652ad..a5c4856291 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsHu extends AppLocalizations { AppLocalizationsHu([String locale = 'hu']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Fényképek'; + + @override + String divelogsSync_photosButton(int count) { + return 'Fényképek szinkronizálása $count párosított merüléshez'; + } + + @override + String get divelogsSync_photosSyncing => + 'Fényképek szinkronizálása a divelogs.de-vel...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '$pulled fénykép letöltve, $pushed feltöltve.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count fénykép már megvolt (tartalom alapján felismerve).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count távoli képnek nem volt letölthető hivatkozása, ezért kimaradtak.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'A fényképszinkronizálás leállt: $error'; + } + @override String get divelogsSync_gearCertHeader => 'Felszerelés és minősítések'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 24aa307d80..9952c47174 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Foto'; + + @override + String divelogsSync_photosButton(int count) { + return 'Sincronizza le foto di $count immersioni corrispondenti'; + } + + @override + String get divelogsSync_photosSyncing => + 'Sincronizzazione foto con divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '$pulled foto scaricate, $pushed caricate.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count foto erano già presenti (riconosciute dal contenuto).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count immagini remote non avevano un link scaricabile e sono state ignorate.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'Sincronizzazione foto interrotta: $error'; + } + @override String get divelogsSync_gearCertHeader => 'Attrezzatura e certificazioni'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 2647800036..44c2476982 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Foto\'s'; + + @override + String divelogsSync_photosButton(int count) { + return 'Foto\'s synchroniseren voor $count gekoppelde duiken'; + } + + @override + String get divelogsSync_photosSyncing => + 'Foto\'s synchroniseren met divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '$pulled foto\'s opgehaald, $pushed verstuurd.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count foto\'s waren al aanwezig (herkend op inhoud).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count externe afbeeldingen hadden geen downloadbare link en zijn overgeslagen.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'Fotosynchronisatie gestopt: $error'; + } + @override String get divelogsSync_gearCertHeader => 'Uitrusting & brevetten'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 463761c9da..15be2db06c 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8,6 +8,38 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get divelogsSync_photosHeader => 'Fotos'; + + @override + String divelogsSync_photosButton(int count) { + return 'Sincronizar fotos de $count mergulhos correspondentes'; + } + + @override + String get divelogsSync_photosSyncing => + 'Sincronizando fotos com divelogs.de...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '$pulled fotos baixadas, $pushed enviadas.'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count fotos já estavam presentes (reconhecidas pelo conteúdo).'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count imagens remotas não tinham link para download e foram ignoradas.'; + } + + @override + String divelogsSync_photosFailed(String error) { + return 'Sincronização de fotos interrompida: $error'; + } + @override String get divelogsSync_gearCertHeader => 'Equipamento e certificações'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 116be3b2bb..8d19bfaab4 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8,6 +8,37 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get divelogsSync_photosHeader => '照片'; + + @override + String divelogsSync_photosButton(int count) { + return '为 $count 条匹配的潜水记录同步照片'; + } + + @override + String get divelogsSync_photosSyncing => '正在与 divelogs.de 同步照片...'; + + @override + String divelogsSync_photosDone(int pulled, int pushed) { + return '拉取了 $pulled 张照片,推送了 $pushed 张。'; + } + + @override + String divelogsSync_photosDuplicates(int count) { + return '$count 张照片已存在(按内容匹配)。'; + } + + @override + String divelogsSync_photosNoUrl(int count) { + return '$count 张远程图片没有可下载链接,已跳过。'; + } + + @override + String divelogsSync_photosFailed(String error) { + return '照片同步已停止:$error'; + } + @override String get divelogsSync_gearCertHeader => '装备与证书'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 183a086cdb..ec64552207 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "Foto's", + "divelogsSync_photosButton": "Foto's synchroniseren voor {count} gekoppelde duiken", + "divelogsSync_photosSyncing": "Foto's synchroniseren met divelogs.de...", + "divelogsSync_photosDone": "{pulled} foto's opgehaald, {pushed} verstuurd.", + "divelogsSync_photosDuplicates": "{count} foto's waren al aanwezig (herkend op inhoud).", + "divelogsSync_photosNoUrl": "{count} externe afbeeldingen hadden geen downloadbare link en zijn overgeslagen.", + "divelogsSync_photosFailed": "Fotosynchronisatie gestopt: {error}", "divelogsSync_gearCertHeader": "Uitrusting & brevetten", "divelogsSync_gearCertMatched": "{gear} uitrustingsstukken en {certs} brevetten al gesynchroniseerd", "divelogsSync_gearCertPush": "Versturen: {gear} uitrustingsstukken, {certs} brevetten", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 63e8ec5dba..a072f19b9d 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "Fotos", + "divelogsSync_photosButton": "Sincronizar fotos de {count} mergulhos correspondentes", + "divelogsSync_photosSyncing": "Sincronizando fotos com divelogs.de...", + "divelogsSync_photosDone": "{pulled} fotos baixadas, {pushed} enviadas.", + "divelogsSync_photosDuplicates": "{count} fotos já estavam presentes (reconhecidas pelo conteúdo).", + "divelogsSync_photosNoUrl": "{count} imagens remotas não tinham link para download e foram ignoradas.", + "divelogsSync_photosFailed": "Sincronização de fotos interrompida: {error}", "divelogsSync_gearCertHeader": "Equipamento e certificações", "divelogsSync_gearCertMatched": "{gear} equipamentos e {certs} certificações já sincronizados", "divelogsSync_gearCertPush": "Enviar: {gear} equipamentos, {certs} certificações", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 57e662f348..7b31ca7920 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -1,4 +1,11 @@ { + "divelogsSync_photosHeader": "照片", + "divelogsSync_photosButton": "为 {count} 条匹配的潜水记录同步照片", + "divelogsSync_photosSyncing": "正在与 divelogs.de 同步照片...", + "divelogsSync_photosDone": "拉取了 {pulled} 张照片,推送了 {pushed} 张。", + "divelogsSync_photosDuplicates": "{count} 张照片已存在(按内容匹配)。", + "divelogsSync_photosNoUrl": "{count} 张远程图片没有可下载链接,已跳过。", + "divelogsSync_photosFailed": "照片同步已停止:{error}", "divelogsSync_gearCertHeader": "装备与证书", "divelogsSync_gearCertMatched": "{gear} 件装备和 {certs} 张证书已同步", "divelogsSync_gearCertPush": "推送:{gear} 件装备,{certs} 张证书", diff --git a/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart b/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart index 236c8fa327..a2aa7b90e7 100644 --- a/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart +++ b/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart @@ -345,4 +345,42 @@ void main() { findsOneWidget, ); }); + + testWidgets('photos section syncs matched dives', (tester) async { + var pictureCalls = 0; + final client = MockClient((req) async { + final fallback = gearCertDefaults(req); + if (fallback != null) return fallback; + if (req.url.path == '/api/divelist') { + return http.Response( + jsonEncode([divelistEntry(1, '2022-09-03', '10:00:00')]), + 200, + ); + } + if (req.url.path.startsWith('/api/pictures/')) { + pictureCalls++; + return http.Response(jsonEncode([]), 200); + } + fail('unexpected request ${req.url}'); + }); + + await tester.runAsync(() async { + await seedAccount(); + await seedLocalDive('local-matched', DateTime.utc(2022, 9, 3, 10)); + await tester.pumpWidget(host(client)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Compare')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(find.text('Sync photos for 1 matched dives'), findsOneWidget); + await tester.ensureVisible(find.text('Sync photos for 1 matched dives')); + await tester.tap(find.text('Sync photos for 1 matched dives')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + }); + + expect(pictureCalls, 1, reason: 'the one matched dive is queried'); + expect(find.text('Pulled 0 photos, pushed 0.'), findsOneWidget); + }); } From 27b5b1c0943f412a4035a5db09dc757b0c553363 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Tue, 4 Aug 2026 21:51:04 -0400 Subject: [PATCH 34/35] Address Copilot review on PR #603 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Close the divelogs http.Client via ref.onDispose so sockets do not outlive the container. - Username field: stays locked to accountIdentifier when one exists, but an identifier-less row (e.g. sync-created) is editable again and the identifier is backfilled on successful login. - Sync planner: replace the O(remote x local) scan with a time-sorted sliding window over the 15-minute gate. - Document why the sync page loads the full logbook (locals outside the remote range ARE the push candidates) and name the sentinel limit. - pt: restore the accent in 'ja sincronizados' -> 'já sincronizados'. --- .../services/divelogs_sync_planner.dart | 47 +++++++++++++++---- .../pages/divelogs_sync_page.dart | 11 ++++- .../data/adapters/divelogs_adapter.dart | 11 +++-- .../widgets/divelogs_fetch_step.dart | 16 ++++++- lib/l10n/arb/app_localizations_pt.dart | 2 +- lib/l10n/arb/app_pt.arb | 2 +- .../services/divelogs_sync_planner_test.dart | 36 ++++++++++++++ .../widgets/divelogs_fetch_step_test.dart | 44 +++++++++++++++++ 8 files changed, 153 insertions(+), 16 deletions(-) diff --git a/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart b/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart index 9a5e7a692a..1adb8ebe8f 100644 --- a/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart +++ b/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart @@ -49,27 +49,52 @@ class DivelogsSyncPlanner { }) { final sortedRemote = [...remote] ..sort((a, b) => a.dateTime.compareTo(b.dateTime)); - final unmatchedLocal = [...local]; + // Both sides sorted by time so each remote entry only scores locals + // inside its 15-minute gate (a sliding window) instead of scanning the + // whole logbook: O((n + m) log(n + m) + window) rather than O(n * m). + final sortedLocal = [...local] + ..sort((a, b) => _localTime(a).compareTo(_localTime(b))); + final matched = List.filled(sortedLocal.length, false); + final matchedIds = {}; final pull = []; final matchedPairs = []; + var windowStart = 0; for (final entry in sortedRemote) { + final lowerBound = entry.dateTime.subtract(_timeGate); + final upperBound = entry.dateTime.add(_timeGate); + // Remote entries are ascending, so locals before this entry's window + // (or already matched at the window's edge) never come back in range. + while (windowStart < sortedLocal.length && + (matched[windowStart] || + _localTime(sortedLocal[windowStart]).isBefore(lowerBound))) { + windowStart++; + } DiveSummary? best; + var bestIndex = -1; var bestKey = double.negativeInfinity; - for (final summary in unmatchedLocal) { - final key = _matchKey(entry, summary); + for ( + var i = windowStart; + i < sortedLocal.length && + !_localTime(sortedLocal[i]).isAfter(upperBound); + i++ + ) { + if (matched[i]) continue; + final key = _matchKey(entry, sortedLocal[i]); if (key != null && key > bestKey) { - best = summary; + best = sortedLocal[i]; + bestIndex = i; bestKey = key; } } if (best != null) { - unmatchedLocal.remove(best); + matched[bestIndex] = true; + matchedIds.add(best.id); matchedPairs.add( DivelogsMatchedDive( remoteId: entry.id, localDiveId: best.id, - localTime: best.entryTime ?? best.dateTime, + localTime: _localTime(best), ), ); } else { @@ -79,16 +104,22 @@ class DivelogsSyncPlanner { return DivelogsSyncPlan( pullCandidates: pull, - pushCandidates: unmatchedLocal, + pushCandidates: [ + for (final summary in local) + if (!matchedIds.contains(summary.id)) summary, + ], matchedPairs: matchedPairs, matchedCount: matchedPairs.length, ); } + static DateTime _localTime(DiveSummary summary) => + summary.entryTime ?? summary.dateTime; + /// Returns a comparable match quality (higher is better), or null when /// the pair does not match. double? _matchKey(DivelogsDivelistEntry entry, DiveSummary summary) { - final localTime = summary.entryTime ?? summary.dateTime; + final localTime = _localTime(summary); final timeDiff = entry.dateTime.difference(localTime).abs(); if (timeDiff > _timeGate) return null; diff --git a/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart index 30e56e1b4a..1f960973be 100644 --- a/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart +++ b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart @@ -50,6 +50,10 @@ class DivelogsSyncPage extends ConsumerStatefulWidget { } class _DivelogsSyncPageState extends ConsumerState { + /// getDiveSummaries has no unbounded mode; this is its "every dive" + /// sentinel (far above any real logbook). + static const int _allDives = 1000000; + _PagePhase _phase = _PagePhase.loading; ConnectedAccount? _account; DivelogsSyncPlan? _plan; @@ -129,9 +133,14 @@ class _DivelogsSyncPageState extends ConsumerState { final remote = await api.getDivelist(); final currentDiver = await ref.read(currentDiverProvider.future); final diverId = account.diverId ?? currentDiver?.id; + // The planner needs the FULL local logbook: dives outside the remote + // list's time range are exactly the push candidates, so constraining + // this query would silently drop them. DiveSummary is the lean list + // projection, and matching is window-bounded, so an all-rows load + // stays cheap even for large logbooks. final local = await ref .read(diveRepositoryProvider) - .getDiveSummaries(diverId: diverId, limit: 1000000); + .getDiveSummaries(diverId: diverId, limit: _allDives); if (!mounted) return; final plan = const DivelogsSyncPlanner().plan( remote: remote.entries, diff --git a/lib/features/import_wizard/data/adapters/divelogs_adapter.dart b/lib/features/import_wizard/data/adapters/divelogs_adapter.dart index 736f891239..fa4973d6d6 100644 --- a/lib/features/import_wizard/data/adapters/divelogs_adapter.dart +++ b/lib/features/import_wizard/data/adapters/divelogs_adapter.dart @@ -14,10 +14,13 @@ final divelogsPayloadReadyProvider = Provider( ); /// HTTP client for divelogs.de calls. Overridable so widget tests can -/// supply a MockClient (pattern: weatherHttpClientProvider). -final divelogsHttpClientProvider = Provider( - (ref) => http.Client(), -); +/// supply a MockClient (pattern: weatherHttpClientProvider). Closed on +/// dispose so the underlying sockets do not outlive the container. +final divelogsHttpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); /// Import source that pulls the user's logbook from divelogs.de. /// diff --git a/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart index 68beabe7be..3c480f80bf 100644 --- a/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart +++ b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart @@ -82,6 +82,9 @@ class _DivelogsFetchStepState extends ConsumerState { ref.read(accountProviderRegistryProvider).adapterFor(AccountKind.divelogs) as DivelogsAccountAdapter; + bool get _accountHasIdentifier => + (_account?.accountIdentifier ?? '').trim().isNotEmpty; + Future _connect() async { final username = _usernameController.text.trim(); final password = _passwordController.text; @@ -97,6 +100,14 @@ class _DivelogsFetchStepState extends ConsumerState { httpClient: ref.read(divelogsHttpClientProvider), ); final repo = ref.read(connectedAccountsRepositoryProvider); + final existing = _account; + if (existing != null && !_accountHasIdentifier) { + // The row predates this login (e.g. arrived via sync) and carries no + // username; the credentials we are about to store belong to the one + // just typed, so record it on the account too. + await repo.updateLabels(existing.id, accountIdentifier: username); + _account = existing.copyWith(accountIdentifier: username); + } final account = _account ?? await repo.create( @@ -218,7 +229,10 @@ class _DivelogsFetchStepState extends ConsumerState { autocorrect: false, // Reconnecting an existing account: the username identifies the // account row (accountIdentifier) and must not drift from it. - enabled: !_connecting && _account == null, + // A row without an identifier (e.g. sync-created) still needs a + // username typed to re-authenticate; the successful login then + // backfills accountIdentifier. + enabled: !_connecting && !_accountHasIdentifier, ), const SizedBox(height: 12), TextField( diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index cc06fe7ad1..2e584602e3 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -95,7 +95,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String divelogsSync_matched(int count) { - return '$count mergulhos ja sincronizados'; + return '$count mergulhos já sincronizados'; } @override diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 955a917ce6..8a6f272d92 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -19,7 +19,7 @@ "divelogsSync_openImport": "Abrir importação do divelogs.de", "divelogsSync_compare": "Comparar", "divelogsSync_comparing": "Comparando com divelogs.de...", - "divelogsSync_matched": "{count} mergulhos ja sincronizados", + "divelogsSync_matched": "{count} mergulhos já sincronizados", "divelogsSync_pullHeader": "Baixar: {count} novos no divelogs.de", "divelogsSync_pullReview": "Revisar e baixar no assistente de importação", "divelogsSync_pushHeader": "Enviar: {count} mergulhos que não estão no divelogs.de", diff --git a/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart b/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart index 1614134b6a..1c8629c758 100644 --- a/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart +++ b/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart @@ -100,4 +100,40 @@ void main() { expect(plan.pullCandidates, hasLength(1)); expect(plan.pushCandidates, isEmpty); }); + + test('push candidates keep the caller\'s input order', () { + // The windowed matcher sorts internally; the plan must still report + // unmatched locals in the order they were handed in. + final unmatchedNewer = local('l-new', t0.add(const Duration(days: 30))); + final unmatchedOlder = local('l-old', t0.subtract(const Duration(days: 9))); + final matchedDive = local('l-match', t0); + final plan = planner.plan( + remote: [remote('r1', t0)], + local: [unmatchedNewer, matchedDive, unmatchedOlder], + ); + expect(plan.pushCandidates.map((s) => s.id), ['l-new', 'l-old']); + }); + + test('a large shuffled logbook matches each remote to its nearest dive', () { + // 500 local dives a day apart, presented unsorted; every remote entry + // sits 5 minutes off one of them. The sliding window must find each + // partner exactly once and pull/push nothing. + final locals = [ + for (var i = 0; i < 500; i++) local('l$i', t0.add(Duration(days: i))), + ]..shuffle(); + final remotes = [ + for (var i = 0; i < 500; i++) + remote('r$i', t0.add(Duration(days: i, minutes: 5))), + ]..shuffle(); + final plan = planner.plan(remote: remotes, local: locals); + expect(plan.matchedCount, 500); + expect(plan.pullCandidates, isEmpty); + expect(plan.pushCandidates, isEmpty); + final pairs = { + for (final p in plan.matchedPairs) p.remoteId: p.localDiveId, + }; + for (var i = 0; i < 500; i++) { + expect(pairs['r$i'], 'l$i'); + } + }); } diff --git a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart index e08960a09f..b983b6fb8f 100644 --- a/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart @@ -143,4 +143,48 @@ void main() { }); }, ); + + testWidgets( + 'an account without accountIdentifier is editable and backfilled on login', + (tester) async { + await tester.runAsync(() async { + // A sync-created row: kind exists but no username was ever recorded + // on this device, and no credentials are in the keychain. + final seedContainer = ProviderContainer(); + final seeded = await seedContainer + .read(connectedAccountsRepositoryProvider) + .create(kind: AccountKind.divelogs, label: 'divelogs.de'); + seedContainer.dispose(); + expect(seeded.accountIdentifier, isNull); + + await tester.pumpWidget(host(loginThenDives())); + await tester.pumpAndSettle(); + + // Without an identifier the username field must stay editable, or + // the user could never re-authenticate this row. + final username = tester.widget(find.byType(TextField).first); + expect(username.enabled, isTrue); + + await tester.enterText(find.byType(TextField).first, 'eric'); + await tester.enterText(find.byType(TextField).at(1), 'secret'); + await tester.ensureVisible(find.text('Connect')); + await tester.tap(find.text('Connect')); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(DivelogsFetchStep)), + ); + final repo = container.read(connectedAccountsRepositoryProvider); + final account = await repo.getByKind(AccountKind.divelogs); + expect(account!.id, seeded.id, reason: 'no second row is minted'); + expect(account.accountIdentifier, 'eric'); + + final blob = DivelogsCredentials.fromJsonString( + await credentialsStore.read(account.id), + ); + expect(blob?.username, 'eric'); + }); + }, + ); } From 5203c6bd5a194d3495b62861aceddfef5825b3c1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 27 Aug 2026 03:31:07 -0400 Subject: [PATCH 35/35] fix: restore main's libdivecomputer submodule pointer The merge of main correctly advanced the gitlink in the index, but the submodule's working tree was still checked out at the branch's older commit, and staging with 'git add -u' re-staged that stale checkout over it. That silently reverted the submodule to e4b10a8b, which predates the patches the C wrapper and Linux builds compile against. --- packages/libdivecomputer_plugin/third_party/libdivecomputer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/libdivecomputer_plugin/third_party/libdivecomputer b/packages/libdivecomputer_plugin/third_party/libdivecomputer index e4b10a8b52..08bf5925e1 160000 --- a/packages/libdivecomputer_plugin/third_party/libdivecomputer +++ b/packages/libdivecomputer_plugin/third_party/libdivecomputer @@ -1 +1 @@ -Subproject commit e4b10a8b52f20c5eeb50d45943dd5f9581297abe +Subproject commit 08bf5925e1c6563a176c420a5c469e625f5b0996