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 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. 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. 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. 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. diff --git a/lib/core/data/repositories/connected_accounts_repository.dart b/lib/core/data/repositories/connected_accounts_repository.dart index 7a97277997..4ea765b83b 100644 --- a/lib/core/data/repositories/connected_accounts_repository.dart +++ b/lib/core/data/repositories/connected_accounts_repository.dart @@ -39,6 +39,7 @@ class ConnectedAccountsRepository { required String label, String? accountIdentifier, String? id, + String? diverId, }) async { final accountId = id ?? _uuid.v4(); final now = DateTime.now().millisecondsSinceEpoch; @@ -52,6 +53,7 @@ class ConnectedAccountsRepository { accountIdentifier: Value(accountIdentifier), createdAt: now, updatedAt: now, + diverId: Value(diverId), ), ); await _markPending(accountId, now); @@ -62,6 +64,7 @@ class ConnectedAccountsRepository { accountIdentifier: accountIdentifier, createdAt: DateTime.fromMillisecondsSinceEpoch(now, isUtc: true), updatedAt: DateTime.fromMillisecondsSinceEpoch(now, isUtc: true), + diverId: diverId, ); } @@ -219,6 +222,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 c530d08c70..a4ad130689 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1578,6 +1578,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}; } @@ -3189,7 +3193,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 = 170; + static const int currentSchemaVersion = 174; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -3393,7 +3397,8 @@ class AppDatabase extends _$AppDatabase { // v137: dives.weather_code, plus a one-time clear of the English weather // prose this app generated itself so it can be re-rendered localized. 137, - // v138 is reserved by the divelogs.de branch (connected_accounts.diver_id). + // v138 is permanently skipped: it was reserved for the divelogs.de + // branch, which moved to 174 once main advanced past it. // v139: cylinder_configs + cylinder_config_items (reusable diluent and // bailout setups). 139, @@ -3514,6 +3519,14 @@ class AppDatabase extends _$AppDatabase { // landed 168 past it, so PR #1276 moved its rung up), and 169 belongs to // PR #1320 (dive-computer gear twins). 170, + // v174: connected_accounts.diver_id (divelogs.de diver binding). + // Renumbered from 116, then 138: main reserved 138 for this branch but + // then advanced to 168, and a rung below the shipped version never runs + // its onUpgrade step. 171, 172 and 173 are claimed by PRs #1319, #1328 and + // #1276, and 169 is permanently skipped now that main landed 170 past + // PR #1320 and that branch moved up to 175, so this takes 174. 138 is + // permanently skipped too: nothing will ever claim it. + 174, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -3553,7 +3566,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')", @@ -3964,6 +3978,21 @@ class AppDatabase extends _$AppDatabase { } } + /// v138: 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', + ); + } + } + /// v129: quality_findings table for the Data Quality Assistant. /// Idempotent so it is safe to call from both onUpgrade and the /// beforeOpen backstop. @@ -8770,6 +8799,12 @@ class AppDatabase extends _$AppDatabase { await _rewriteLegacySacRateLayouts(); } if (from < 170) await reportProgress(); + // v174: connected_accounts.diver_id (divelogs.de diver binding). + // Renumbered from 138, which main advanced past; see the ladder note. + if (from < 174) { + await _assertConnectedAccountsDiverIdColumn(); + } + if (from < 174) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -9001,6 +9036,9 @@ class AppDatabase extends _$AppDatabase { // orphaned buddy_roles table whose credentials silently vanish from // the UI forever (nothing else reads that table). await _migrateBuddyRolesToCertifications(); + // v174 backstop: re-assert connected_accounts.diver_id column + // (parallel-branch collision self-heal). + await _assertConnectedAccountsDiverIdColumn(); // Built-in dive types are reference data: identical on every device and // undeletable through DiveTypeRepository. Nothing else restores them -- diff --git a/lib/core/providers/account_providers.dart b/lib/core/providers/account_providers.dart index dd3ad8de6f..5619bde57f 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'; @@ -45,5 +46,8 @@ final accountProviderRegistryProvider = Provider( GoogleDriveAccountAdapter(), ICloudAccountAdapter(), LightroomAccountAdapter(), + DivelogsAccountAdapter( + credentials: ref.watch(accountCredentialsStoreProvider), + ), ]), ); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index c734cadd43..0b152a8235 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -153,6 +153,8 @@ 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'; import 'package:submersion/shared/widgets/main_scaffold.dart'; @@ -876,6 +878,11 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const _UniversalImportWizardRoute(), ), + GoRoute( + path: 'divelogs-import', + name: 'divelogsImport', + builder: (context, state) => const _DivelogsImportWizardRoute(), + ), ], ), @@ -1149,6 +1156,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', @@ -1598,6 +1610,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/core/services/accounts/account_identity.dart b/lib/core/services/accounts/account_identity.dart index a4027457a0..28899d7640 100644 --- a/lib/core/services/accounts/account_identity.dart +++ b/lib/core/services/accounts/account_identity.dart @@ -28,7 +28,8 @@ String s3NaturalKey(S3Config config) => String? naturalKeyForKind(AccountKind kind) => switch (kind) { AccountKind.icloud || AccountKind.dropbox || - AccountKind.googledrive => kind.name, + AccountKind.googledrive || + AccountKind.divelogs => kind.name, AccountKind.s3 || AccountKind.adobeLightroom => null, }; 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_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/account_startup_migration.dart b/lib/core/services/accounts/account_startup_migration.dart index 25c0e79b00..76b7f414dd 100644 --- a/lib/core/services/accounts/account_startup_migration.dart +++ b/lib/core/services/accounts/account_startup_migration.dart @@ -101,6 +101,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. } @@ -207,5 +208,6 @@ class AccountStartupMigration { AccountKind.icloud => 'iCloud', AccountKind.s3 => 'S3', AccountKind.adobeLightroom => 'Lightroom', + AccountKind.divelogs => 'divelogs.de', }; } 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/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/lib/core/services/divelogs/divelogs_api_client.dart b/lib/core/services/divelogs/divelogs_api_client.dart new file mode 100644 index 0000000000..ebcedcf49f --- /dev/null +++ b/lib/core/services/divelogs/divelogs_api_client.dart @@ -0,0 +1,371 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +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 = _decode(response.body, '/user'); + 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 = _decode(response.body, '/dives'); + 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); + } + + /// 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); + } + + 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; + } + } + + 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) { + 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. + Object? _decode(String body, String endpoint) { + try { + return jsonDecode(body); + } on FormatException { + throw DivelogsApiException(0, 'Unexpected $endpoint response'); + } + } + + 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 = 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; + } + } +} 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/lib/core/services/divelogs/divelogs_models.dart b/lib/core/services/divelogs/divelogs_models.dart new file mode 100644 index 0000000000..3f2ba5d1a6 --- /dev/null +++ b/lib/core/services/divelogs/divelogs_models.dart @@ -0,0 +1,347 @@ +/// 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) { + final num n => n.toDouble(), + final String s => double.tryParse(s), + _ => null, +}; + +int? _asInt(Object? v) => switch (v) { + final num n => n.toInt(), + final 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; +} + +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; + + 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; + final List gearItemIds; + + 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, + this.gearItemIds = const [], + }); + + 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); + } + // 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}T${time}Z'); + } 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']), + gearItemIds: json['gearitems'] is List + ? [for (final g in json['gearitems'] as List) '$g'] + : const [], + ); + } +} + +class DivelogsDivesResult { + final List dives; + final int skippedCount; + + 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}); +} + +/// 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']), + ); + } +} + +/// 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/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..0611717366 --- /dev/null +++ b/lib/features/divelogs_sync/data/mappers/divelogs_export_mapper.dart @@ -0,0 +1,115 @@ +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 +/// 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, { + Map remoteGearIdByName = const {}, + }) { + 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': 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; + 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; + } + + 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) { + 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/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/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..812f6d1c65 --- /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_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/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..d19eea1880 --- /dev/null +++ b/lib/features/divelogs_sync/domain/services/divelogs_push_service.dart @@ -0,0 +1,75 @@ +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, + Map remoteGearIdByName = const {}, + }) async { + final mapped = >[]; + var skipped = 0; + for (final dive in dives) { + final json = mapper.mapDive(dive, remoteGearIdByName: remoteGearIdByName); + 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/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..1adb8ebe8f --- /dev/null +++ b/lib/features/divelogs_sync/domain/services/divelogs_sync_planner.dart @@ -0,0 +1,151 @@ +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, + }); +} + +/// 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)); + // 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 ( + 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 = sortedLocal[i]; + bestIndex = i; + bestKey = key; + } + } + if (best != null) { + matched[bestIndex] = true; + matchedIds.add(best.id); + matchedPairs.add( + DivelogsMatchedDive( + remoteId: entry.id, + localDiveId: best.id, + localTime: _localTime(best), + ), + ); + } else { + pull.add(entry); + } + } + + return DivelogsSyncPlan( + pullCandidates: pull, + 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 = _localTime(summary); + 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/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/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..1f960973be --- /dev/null +++ b/lib/features/divelogs_sync/presentation/pages/divelogs_sync_page.dart @@ -0,0 +1,637 @@ +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'; +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/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_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 { + 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 { + /// 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; + Set _selectedPushIds = {}; + String? _errorMessage; + int _pushDone = 0; + int _pushTotal = 0; + DivelogsPushResult? _lastPushResult; + GearCertSyncPlan? _gearCertPlan; + Map _geartypes = const {}; + Map _remoteGearIdByName = const {}; + String? _gearCertError; + GearCertPushResult? _lastGearCertResult; + bool _pushingGearCerts = false; + List _matchedPairs = const []; + PhotoSyncResult? _lastPhotoResult; + bool _syncingPhotos = false; + int _photoSyncDone = 0; + int _photoSyncTotal = 0; + + @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 api = _api(account); + 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: _allDives); + if (!mounted) return; + final plan = const DivelogsSyncPlanner().plan( + 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(); + _matchedPairs = plan.matchedPairs; + _gearCertPlan = gearCertPlan; + _gearCertError = gearCertError; + _geartypes = geartypes; + _remoteGearIdByName = remoteGearIdByName; + _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, + remoteGearIdByName: _remoteGearIdByName, + 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(); + } + + 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(); + } + + 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; + 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), + ), + ], + 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; + 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; + final number = summary.diveNumber; + if (number != null) return '#$number'; + return summary.siteName ?? summary.id; + } +} 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..fa4973d6d6 --- /dev/null +++ b/lib/features/import_wizard/data/adapters/divelogs_adapter.dart @@ -0,0 +1,46 @@ +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. Overridable so widget tests can +/// 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. +/// +/// 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 34a893b466..97c118da57 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -252,12 +252,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 {}, ); } @@ -336,10 +333,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 6e0fe1bfad..d4ac8b4ae6 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..3c480f80bf --- /dev/null +++ b/lib/features/import_wizard/presentation/widgets/divelogs_fetch_step.dart @@ -0,0 +1,339 @@ +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_identity.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'; +import 'package:submersion/l10n/l10n_extension.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; + + bool get _accountHasIdentifier => + (_account?.accountIdentifier ?? '').trim().isNotEmpty; + + 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 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( + kind: AccountKind.divelogs, + label: 'divelogs.de', + accountIdentifier: username, + diverId: _selectedDiverId, + // Deterministic id: every device connecting divelogs.de derives + // the same primary key, so sync's upsert-by-id merges the rows + // instead of unioning two accounts. + id: accountIdFor( + kind: AccountKind.divelogs, + naturalKey: naturalKeyForKind(AccountKind.divelogs)!, + ), + ); + 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; + final installed = await ref + .read(universalImportNotifierProvider.notifier) + .setExternalPayload(payload); + if (!mounted) return; + setState(() { + _phase = installed ? _StepPhase.done : _StepPhase.error; + if (!installed) _errorMessage = null; + }); + } 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, + context.l10n.divelogs_fetch_done, + ), + _StepPhase.wrongDiver => _buildMessage( + context, + context.l10n.divelogs_fetch_wrongDiver, + ), + _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( + context.l10n.divelogs_signIn_title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + controller: _usernameController, + decoration: InputDecoration( + labelText: context.l10n.divelogs_signIn_username, + ), + autocorrect: false, + // Reconnecting an existing account: the username identifies the + // account row (accountIdentifier) and must not drift from it. + // 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( + controller: _passwordController, + decoration: InputDecoration( + labelText: context.l10n.divelogs_signIn_password, + ), + obscureText: true, + enabled: !_connecting, + onSubmitted: (_) => _connect(), + ), + const SizedBox(height: 12), + divers.when( + data: (list) => DropdownButtonFormField( + initialValue: _selectedDiverId, + decoration: InputDecoration( + labelText: context.l10n.divelogs_signIn_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), + ) + : Text(context.l10n.divelogs_signIn_connect), + ), + ], + ), + ); + } + + Widget _buildProgress(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(context.l10n.divelogs_fetch_inProgress), + ], + ), + ); + } + + 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 ?? context.l10n.divelogs_fetch_error, + 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: Text(context.l10n.divelogs_fetch_retry), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/pages/connected_accounts_page.dart b/lib/features/settings/presentation/pages/connected_accounts_page.dart index 284c150e16..bbdbc85fc6 100644 --- a/lib/features/settings/presentation/pages/connected_accounts_page.dart +++ b/lib/features/settings/presentation/pages/connected_accounts_page.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/providers/account_providers.dart'; @@ -79,6 +81,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 @@ -101,6 +104,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/features/settings/presentation/providers/sync_providers.dart b/lib/features/settings/presentation/providers/sync_providers.dart index ab3bc467a2..b0a79b1142 100644 --- a/lib/features/settings/presentation/providers/sync_providers.dart +++ b/lib/features/settings/presentation/providers/sync_providers.dart @@ -407,7 +407,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/lib/features/transfer/presentation/pages/transfer_page.dart b/lib/features/transfer/presentation/pages/transfer_page.dart index d8fc4ffe81..7c2486bd01 100644 --- a/lib/features/transfer/presentation/pages/transfer_page.dart +++ b/lib/features/transfer/presentation/pages/transfer_page.dart @@ -280,6 +280,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/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..c42c95e63f --- /dev/null +++ b/lib/features/universal_import/data/services/divelogs_dive_mapper.dart @@ -0,0 +1,120 @@ +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()}'; + + /// 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, + '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}'; + if (dive.gearItemIds.isNotEmpty) { + map['equipmentRefs'] = [for (final id in dive.gearItemIds) gearKey(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/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..5249a114e8 --- /dev/null +++ b/lib/features/universal_import/data/services/divelogs_import_service.dart @@ -0,0 +1,138 @@ +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'; +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(); + + // 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) { + diveEntities.add(_mapper.mapDive(dive)); + final site = _mapper.mapSite(dive); + if (site != null) { + 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); + } + } + } + + 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; + } + 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, + warnings: [ + if (result.skippedCount > 0) + ImportWarning( + severity: ImportWarningSeverity.warning, + 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.', + ), + ...extraWarnings, + ], + 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 65ebdca289..f33650e6e1 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -795,6 +795,35 @@ 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. + /// + /// 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); + 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 -- Future _parseAndCheckDuplicates() async { diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 690c15e2fc..aa6a41e2e3 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "عمق الغوص - عينات العمق المسجلة أثناء الغطسات", "settings_dataSources_appleHealth_dataTypeWaterTemp": "درجة حرارة الماء - عينات درجة الحرارة المسجلة أثناء الغطسات", "settings_dataSources_appleHealth_permissionManagedInHealth": "تتم إدارة وصول HealthKit من تطبيق صحة", - "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit غير متوفر على هذا الجهاز" + "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit غير متوفر على هذا الجهاز", + "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} شهادات", + "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", + "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": "كلمة المرور", + "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", + "buddies_section_professionalRoles": "الأدوار المهنية", + "buddies_roles_addRole": "إضافة دور", + "buddies_roles_role": "الدور", + "buddies_roles_agency": "الجهة", + "buddies_roles_credentialNumber": "رقم الاعتماد", + "buddies_roles_removeTooltip": "إزالة الدور", + "buddies_roles_emptyHint": "أضف بيانات اعتماد المدرب أو مدرب الغوص الرئيسي لإعادة استخدامها عند تسجيل الشهادات والدورات.", + "buddies_detail_section_professionalRoles": "الأدوار المهنية", + "certifications_detail_label_level": "المستوى", + "certifications_edit_hint_certificationName": "مثال: غواص مياه مفتوحة", + "certifications_edit_label_certificationName": "اسم الشهادة *", + "certifications_edit_label_level": "المستوى", + "certifications_edit_level_notSpecified": "غير محدد", + "certifications_edit_validation_nameRequired": "يرجى إدخال اسم الشهادة", + "certifications_walletCard_countPlural": "{count} شهادات", + "certifications_walletCard_countSingular": "{count} شهادة", + "certifications_walletCard_emptyFooter": "أضف شهادتك الأولى", + "certifications_walletCard_error": "فشل في تحميل الشهادات", + "certifications_walletCard_semanticLabel": "محفظة الشهادات. انقر لعرض جميع الشهادات", + "certifications_walletCard_tapToAdd": "انقر للإضافة", + "certifications_walletCard_title": "محفظة الشهادات", + "preDive_section_title": "فحص ما قبل الغوص", + "preDive_section_link": "ربط جلسة قائمة تحقق", + "preDive_section_unlink": "إلغاء الربط", + "preDive_section_run": "تشغيل قائمة تحقق ما قبل الغوص", + "preDive_section_noUnlinked": "لا توجد جلسات قوائم تحقق غير مرتبطة", + "diveDetailSection_preDiveChecklist_name": "فحص ما قبل الغوص", + "diveDetailSection_preDiveChecklist_description": "جلسة قائمة تحقق ما قبل الغوص المرتبطة", + "diveCenters_summary_topRated": "الأعلى تقييماً", + "diveLog_instruments_customize": "تخصيص الأدوات", + "diveLog_instruments_customizeHint": "قم بتشغيل الأدوات أو إيقافها. اسحب لإعادة الترتيب.", + "enum_buddyRole_buddy": "زميل غوص", + "enum_buddyRole_diveGuide": "مرشد غوص", + "enum_buddyRole_diveMaster": "مدرب غوص رئيسي", + "enum_buddyRole_instructor": "مدرب", + "enum_buddyRole_solo": "منفرد", + "enum_buddyRole_student": "طالب", + "equipment_addSheet_brandHint": "مثال: Scubapro", + "equipment_addSheet_brandLabel": "العلامة التجارية", + "equipment_addSheet_closeTooltip": "إغلاق", + "equipment_addSheet_currencyLabel": "العملة", + "equipment_addSheet_dateLabel": "التاريخ", + "equipment_addSheet_errorSnackbar": "خطأ في إضافة المعدات: {error}", + "equipment_addSheet_modelHint": "مثال: MK25 EVO", + "equipment_addSheet_modelLabel": "الطراز", + "equipment_addSheet_nameHint": "مثال: منظم الغوص الرئيسي", + "equipment_addSheet_nameLabel": "الاسم", + "equipment_addSheet_nameValidation": "يرجى إدخال اسم", + "equipment_addSheet_notesHint": "ملاحظات إضافية...", + "equipment_addSheet_notesLabel": "ملاحظات", + "equipment_addSheet_priceLabel": "السعر", + "equipment_addSheet_purchaseInfoTitle": "معلومات الشراء", + "equipment_addSheet_serialNumberLabel": "الرقم التسلسلي", + "equipment_addSheet_serviceIntervalHint": "مثال: 365 للصيانة السنوية", + "equipment_addSheet_serviceIntervalLabel": "فترة الصيانة (بالأيام)", + "equipment_addSheet_sizeHint": "مثال: M, L, 42", + "equipment_addSheet_sizeLabel": "المقاس", + "equipment_addSheet_submitButton": "إضافة معدات", + "equipment_addSheet_successSnackbar": "تمت إضافة المعدات بنجاح", + "equipment_addSheet_title": "إضافة معدات", + "equipment_addSheet_typeLabel": "النوع", + "media_diveMediaSection_unlinkDialogContent": "هل تريد إزالة هذه الصورة من الغوصة؟ ستبقى الصورة في معرض الصور.", + "media_diveMediaSection_unlinkDialogTitle": "إلغاء ربط الصورة", + "media_diveMediaSection_unlinkSuccess": "تم إلغاء ربط الصورة", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{جهاز واحد يتزامن من إصدار أحدث من Submersion. حدّث هذا الجهاز لتلقي أحدث تغييراته.} other{{count} أجهزة تتزامن من إصدار أحدث من Submersion. حدّث هذا الجهاز لتلقي أحدث تغييراتها.}}", + "settings_notifications_disabled_enableButton": "تمكين", + "surfaceInterval_secondDive_gasAir": "(هواء)", + "trips_detail_stat_totalBottomTime": "إجمالي وقت القاع", + "dashboard_photos_title": "أحدث الصور", + "diveComputer_detail_cannotFilterNoSerial": "لا يمكن التصفية: لا يوجد رقم تسلسلي لهذا الكمبيوتر." } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 84c39dd86a..b3fda65905 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "Unterwassertiefe - während Tauchgängen aufgezeichnete Tiefenwerte", "settings_dataSources_appleHealth_dataTypeWaterTemp": "Wassertemperatur - während Tauchgängen aufgezeichnete Temperaturwerte", "settings_dataSources_appleHealth_permissionManagedInHealth": "Der HealthKit-Zugriff wird in der Health-App verwaltet", - "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit ist auf diesem Gerät nicht verfügbar" + "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit ist auf diesem Gerät nicht verfügbar", + "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", + "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", + "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", + "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", + "buddies_section_professionalRoles": "Berufliche Rollen", + "buddies_roles_addRole": "Rolle hinzufügen", + "buddies_roles_role": "Rolle", + "buddies_roles_agency": "Organisation", + "buddies_roles_credentialNumber": "Zertifizierungsnummer", + "buddies_roles_removeTooltip": "Rolle entfernen", + "buddies_roles_emptyHint": "Fügen Sie Tauchlehrer- oder Divemaster-Qualifikationen hinzu, um sie beim Erfassen von Zertifizierungen und Kursen wiederzuverwenden.", + "buddies_detail_section_professionalRoles": "Berufliche Rollen", + "certifications_detail_label_level": "Stufe", + "certifications_edit_hint_certificationName": "z. B. Open Water Diver", + "certifications_edit_label_certificationName": "Zertifizierungsname *", + "certifications_edit_label_level": "Stufe", + "certifications_edit_level_notSpecified": "Nicht angegeben", + "certifications_edit_validation_nameRequired": "Bitte geben Sie einen Zertifizierungsnamen ein", + "certifications_walletCard_countPlural": "{count} Zertifizierungen", + "certifications_walletCard_countSingular": "{count} Zertifizierung", + "certifications_walletCard_emptyFooter": "Fügen Sie Ihre erste Zertifizierung hinzu", + "certifications_walletCard_error": "Zertifizierungen konnten nicht geladen werden", + "certifications_walletCard_semanticLabel": "Zertifizierungskartei. Tippen, um alle Zertifizierungen anzuzeigen", + "certifications_walletCard_tapToAdd": "Tippen zum Hinzufügen", + "certifications_walletCard_title": "Zertifizierungskartei", + "preDive_section_title": "Check vor dem Tauchgang", + "preDive_section_link": "Checklisten-Durchlauf verknüpfen", + "preDive_section_unlink": "Verknüpfung aufheben", + "preDive_section_run": "Checkliste vor dem Tauchgang durchführen", + "preDive_section_noUnlinked": "Keine unverknüpften Checklisten-Durchläufe", + "diveDetailSection_preDiveChecklist_name": "Check vor dem Tauchgang", + "diveDetailSection_preDiveChecklist_description": "Verknüpfter Checklisten-Durchlauf vor dem Tauchgang", + "diveCenters_summary_topRated": "Bestbewertet", + "diveLog_instruments_customize": "Instrumente anpassen", + "diveLog_instruments_customizeHint": "Instrumente ein- oder ausschalten. Zum Sortieren ziehen.", + "enum_buddyRole_buddy": "Tauchpartner", + "enum_buddyRole_diveGuide": "Tauchguide", + "enum_buddyRole_diveMaster": "Divemaster", + "enum_buddyRole_instructor": "Tauchlehrer", + "enum_buddyRole_solo": "Solo", + "enum_buddyRole_student": "Tauchschüler", + "equipment_addSheet_brandHint": "z. B. Scubapro", + "equipment_addSheet_brandLabel": "Marke", + "equipment_addSheet_closeTooltip": "Schließen", + "equipment_addSheet_currencyLabel": "Währung", + "equipment_addSheet_dateLabel": "Datum", + "equipment_addSheet_errorSnackbar": "Fehler beim Hinzufügen der Ausrüstung: {error}", + "equipment_addSheet_modelHint": "z. B. MK25 EVO", + "equipment_addSheet_modelLabel": "Modell", + "equipment_addSheet_nameHint": "z. B. Mein Hauptatemregler", + "equipment_addSheet_nameLabel": "Name", + "equipment_addSheet_nameValidation": "Bitte geben Sie einen Namen ein", + "equipment_addSheet_notesHint": "Zusätzliche Notizen...", + "equipment_addSheet_notesLabel": "Notizen", + "equipment_addSheet_priceLabel": "Preis", + "equipment_addSheet_purchaseInfoTitle": "Kaufinformationen", + "equipment_addSheet_serialNumberLabel": "Seriennummer", + "equipment_addSheet_serviceIntervalHint": "z. B. 365 für jährlich", + "equipment_addSheet_serviceIntervalLabel": "Wartungsintervall (Tage)", + "equipment_addSheet_sizeHint": "z. B. M, L, 42", + "equipment_addSheet_sizeLabel": "Größe", + "equipment_addSheet_submitButton": "Ausrüstung hinzufügen", + "equipment_addSheet_successSnackbar": "Ausrüstung erfolgreich hinzugefügt", + "equipment_addSheet_title": "Ausrüstung hinzufügen", + "equipment_addSheet_typeLabel": "Typ", + "media_diveMediaSection_unlinkDialogContent": "Dieses Foto vom Tauchgang entfernen? Das Foto bleibt in Ihrer Galerie erhalten.", + "media_diveMediaSection_unlinkDialogTitle": "Foto trennen", + "media_diveMediaSection_unlinkSuccess": "Foto getrennt", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 Gerät synchronisiert von einer neueren Version von Submersion. Aktualisieren Sie dieses Gerät, um dessen neueste Änderungen zu erhalten.} other{{count} Geräte synchronisieren von einer neueren Version von Submersion. Aktualisieren Sie dieses Gerät, um deren neueste Änderungen zu erhalten.}}", + "settings_notifications_disabled_enableButton": "Aktivieren", + "surfaceInterval_secondDive_gasAir": "(Luft)", + "trips_detail_stat_totalBottomTime": "Gesamte Grundzeit", + "dashboard_photos_title": "Aktuelle Fotos", + "diveComputer_detail_cannotFilterNoSerial": "Filtern nicht möglich: keine Seriennummer für diesen Computer." } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9cf66e463a..70403e95f0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7301,7 +7301,6 @@ } } }, - "media_siteMediaSection_unlinkSelectedSuccess": "Unlinked {count} items", "media_documentViewer_title": "Document", "media_documentViewer_unavailable": "This document is not available on this device", @@ -7833,17 +7832,6 @@ "@media_import_review_chooseDive": { "description": "Review row menu: pick a dive" }, - - - - - - - - - - - "media_import_intro": "Photos are linked to a dive or a dive site as you import them.", "@media_import_intro": { "description": "Import section intro text" @@ -8134,8 +8122,6 @@ } } }, - - "media_library_filter_clear": "Clear filters", "media_library_filter_any": "Any", "@media_library_filter_any": { @@ -19147,5 +19133,158 @@ "@settings_dataSources_appleHealth_permissionUnsupported": {"description": "Permission row shown when HealthKit is unavailable on this device."}, "@settings_units_gasConsumption_sac_subtitle": {"placeholders": {"unit": {"type": "String"}}}, "@settings_units_gasConsumption_rmv_subtitle": {"placeholders": {"unit": {"type": "String"}}}, - "@diveLog_detail_sacVolumeHint": {"placeholders": {"unit": {"type": "String"}}} + "@diveLog_detail_sacVolumeHint": {"placeholders": {"unit": {"type": "String"}}}, + "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"}}}, + "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", + "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", + "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", + "buddies_section_professionalRoles": "Professional Roles", + "buddies_roles_addRole": "Add role", + "buddies_roles_role": "Role", + "buddies_roles_agency": "Agency", + "buddies_roles_credentialNumber": "Credential number", + "buddies_roles_removeTooltip": "Remove role", + "buddies_roles_emptyHint": "Add instructor or divemaster credentials to reuse them when logging certifications and courses.", + "buddies_detail_section_professionalRoles": "Professional Roles", + "certifications_detail_label_level": "Level", + "certifications_edit_hint_certificationName": "e.g., Open Water Diver", + "certifications_edit_label_certificationName": "Certification Name *", + "certifications_edit_label_level": "Level", + "certifications_edit_level_notSpecified": "Not specified", + "certifications_edit_validation_nameRequired": "Please enter a certification name", + "certifications_walletCard_countPlural": "{count} certifications", + "certifications_walletCard_countSingular": "{count} certification", + "certifications_walletCard_emptyFooter": "Add your first certification", + "certifications_walletCard_error": "Failed to load certifications", + "certifications_walletCard_semanticLabel": "Certification Wallet. Tap to view all certifications", + "certifications_walletCard_tapToAdd": "Tap to add", + "certifications_walletCard_title": "Certification Wallet", + "@certifications_walletCard_countPlural": { + "placeholders": { + "count": { + "type": "Object" + } + } + }, + "@certifications_walletCard_countSingular": { + "placeholders": { + "count": { + "type": "Object" + } + } + }, + "preDive_section_title": "Pre-Dive Check", + "preDive_section_link": "Link a checklist session", + "preDive_section_unlink": "Unlink", + "preDive_section_run": "Run pre-dive checklist", + "preDive_section_noUnlinked": "No unlinked checklist sessions", + "diveDetailSection_preDiveChecklist_name": "Pre-Dive Check", + "diveDetailSection_preDiveChecklist_description": "Linked pre-dive checklist session", + "dashboard_photos_title": "Recent photos", + "diveCenters_summary_topRated": "Top Rated", + "diveLog_instruments_customize": "Customize instruments", + "diveLog_instruments_customizeHint": "Toggle instruments on or off. Drag to reorder.", + "enum_buddyRole_buddy": "Buddy", + "enum_buddyRole_diveGuide": "Dive Guide", + "enum_buddyRole_diveMaster": "Divemaster", + "enum_buddyRole_instructor": "Instructor", + "enum_buddyRole_solo": "Solo", + "enum_buddyRole_student": "Student", + "equipment_addSheet_brandHint": "e.g., Scubapro", + "equipment_addSheet_brandLabel": "Brand", + "equipment_addSheet_closeTooltip": "Close", + "equipment_addSheet_currencyLabel": "Currency", + "equipment_addSheet_dateLabel": "Date", + "equipment_addSheet_errorSnackbar": "Error adding equipment: {error}", + "equipment_addSheet_modelHint": "e.g., MK25 EVO", + "equipment_addSheet_modelLabel": "Model", + "equipment_addSheet_nameHint": "e.g., My Primary Regulator", + "equipment_addSheet_nameLabel": "Name", + "equipment_addSheet_nameValidation": "Please enter a name", + "equipment_addSheet_notesHint": "Additional notes...", + "equipment_addSheet_notesLabel": "Notes", + "equipment_addSheet_priceLabel": "Price", + "equipment_addSheet_purchaseInfoTitle": "Purchase Information", + "equipment_addSheet_serialNumberLabel": "Serial Number", + "equipment_addSheet_serviceIntervalHint": "e.g., 365 for yearly", + "equipment_addSheet_serviceIntervalLabel": "Service Interval (days)", + "equipment_addSheet_sizeHint": "e.g., M, L, 42", + "equipment_addSheet_sizeLabel": "Size", + "equipment_addSheet_submitButton": "Add Equipment", + "equipment_addSheet_successSnackbar": "Equipment added successfully", + "equipment_addSheet_title": "Add Equipment", + "equipment_addSheet_typeLabel": "Type", + "@equipment_addSheet_errorSnackbar": { + "placeholders": { + "error": { + "type": "Object" + } + } + }, + "media_diveMediaSection_unlinkDialogContent": "Remove this photo from the dive? The photo will remain in your gallery.", + "media_diveMediaSection_unlinkDialogTitle": "Unlink Photo", + "media_diveMediaSection_unlinkSuccess": "Photo unlinked", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 device syncs from a newer version of Submersion. Update this device to receive its latest changes.} other{{count} devices sync from a newer version of Submersion. Update this device to receive their latest changes.}}", + "settings_notifications_disabled_enableButton": "Enable", + "surfaceInterval_secondDive_gasAir": "(Air)", + "@surfaceInterval_secondDive_gasAir": { + "description": "Label indicating the second dive uses air" + }, + "trips_detail_stat_totalBottomTime": "Total Bottom Time", + "diveComputer_detail_cannotFilterNoSerial": "Cannot filter: no serial number for this computer." } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 494aa6f125..39dbdca801 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "Profundidad bajo el agua - muestras de profundidad registradas durante las inmersiones", "settings_dataSources_appleHealth_dataTypeWaterTemp": "Temperatura del agua - muestras de temperatura registradas durante las inmersiones", "settings_dataSources_appleHealth_permissionManagedInHealth": "El acceso a HealthKit se gestiona en la app Salud", - "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit no está disponible en este dispositivo" + "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit no está disponible en este dispositivo", + "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", + "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", + "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", + "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", + "buddies_section_professionalRoles": "Roles Profesionales", + "buddies_roles_addRole": "Agregar rol", + "buddies_roles_role": "Rol", + "buddies_roles_agency": "Agencia", + "buddies_roles_credentialNumber": "Número de credencial", + "buddies_roles_removeTooltip": "Quitar rol", + "buddies_roles_emptyHint": "Agrega las credenciales de instructor o divemaster para reutilizarlas al registrar certificaciones y cursos.", + "buddies_detail_section_professionalRoles": "Roles Profesionales", + "certifications_detail_label_level": "Nivel", + "certifications_edit_hint_certificationName": "p. ej., Open Water Diver", + "certifications_edit_label_certificationName": "Nombre de la certificacion *", + "certifications_edit_label_level": "Nivel", + "certifications_edit_level_notSpecified": "No especificado", + "certifications_edit_validation_nameRequired": "Por favor, introduce un nombre de certificacion", + "certifications_walletCard_countPlural": "{count} certificaciones", + "certifications_walletCard_countSingular": "{count} certificacion", + "certifications_walletCard_emptyFooter": "Agrega tu primera certificacion", + "certifications_walletCard_error": "Error al cargar certificaciones", + "certifications_walletCard_semanticLabel": "Cartera de certificaciones. Toca para ver todas las certificaciones", + "certifications_walletCard_tapToAdd": "Toca para agregar", + "certifications_walletCard_title": "Cartera de certificaciones", + "preDive_section_title": "Comprobación previa a la inmersión", + "preDive_section_link": "Vincular una sesión de lista de verificación", + "preDive_section_unlink": "Desvincular", + "preDive_section_run": "Ejecutar lista previa a la inmersión", + "preDive_section_noUnlinked": "No hay sesiones de lista sin vincular", + "diveDetailSection_preDiveChecklist_name": "Comprobación previa a la inmersión", + "diveDetailSection_preDiveChecklist_description": "Sesión de lista previa a la inmersión vinculada", + "diveCenters_summary_topRated": "Mejor Calificados", + "diveLog_instruments_customize": "Personalizar instrumentos", + "diveLog_instruments_customizeHint": "Activa o desactiva instrumentos. Arrastra para reordenar.", + "enum_buddyRole_buddy": "Compañero", + "enum_buddyRole_diveGuide": "Guía de buceo", + "enum_buddyRole_diveMaster": "Divemaster", + "enum_buddyRole_instructor": "Instructor", + "enum_buddyRole_solo": "Solo", + "enum_buddyRole_student": "Estudiante", + "equipment_addSheet_brandHint": "p. ej., Scubapro", + "equipment_addSheet_brandLabel": "Marca", + "equipment_addSheet_closeTooltip": "Cerrar", + "equipment_addSheet_currencyLabel": "Moneda", + "equipment_addSheet_dateLabel": "Fecha", + "equipment_addSheet_errorSnackbar": "Error al agregar equipo: {error}", + "equipment_addSheet_modelHint": "p. ej., MK25 EVO", + "equipment_addSheet_modelLabel": "Modelo", + "equipment_addSheet_nameHint": "p. ej., Mi regulador principal", + "equipment_addSheet_nameLabel": "Nombre", + "equipment_addSheet_nameValidation": "Por favor ingresa un nombre", + "equipment_addSheet_notesHint": "Notas adicionales...", + "equipment_addSheet_notesLabel": "Notas", + "equipment_addSheet_priceLabel": "Precio", + "equipment_addSheet_purchaseInfoTitle": "Informacion de compra", + "equipment_addSheet_serialNumberLabel": "Numero de serie", + "equipment_addSheet_serviceIntervalHint": "p. ej., 365 para anual", + "equipment_addSheet_serviceIntervalLabel": "Intervalo de servicio (dias)", + "equipment_addSheet_sizeHint": "p. ej., M, L, 42", + "equipment_addSheet_sizeLabel": "Talla", + "equipment_addSheet_submitButton": "Agregar equipo", + "equipment_addSheet_successSnackbar": "Equipo agregado exitosamente", + "equipment_addSheet_title": "Agregar equipo", + "equipment_addSheet_typeLabel": "Tipo", + "media_diveMediaSection_unlinkDialogContent": "Eliminar esta foto de la inmersion? La foto permanecera en tu galeria.", + "media_diveMediaSection_unlinkDialogTitle": "Desvincular foto", + "media_diveMediaSection_unlinkSuccess": "Foto desvinculada", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 dispositivo sincroniza desde una versión más reciente de Submersion. Actualiza este dispositivo para recibir sus últimos cambios.} other{{count} dispositivos sincronizan desde una versión más reciente de Submersion. Actualiza este dispositivo para recibir sus últimos cambios.}}", + "settings_notifications_disabled_enableButton": "Activar", + "surfaceInterval_secondDive_gasAir": "(Aire)", + "trips_detail_stat_totalBottomTime": "Tiempo de fondo total", + "dashboard_photos_title": "Fotos recientes", + "diveComputer_detail_cannotFilterNoSerial": "No se puede filtrar: sin numero de serie para este ordenador." } diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index b7dcfc205d..2a557bac43 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "Profondeur sous l'eau - mesures de profondeur enregistrées pendant les plongées", "settings_dataSources_appleHealth_dataTypeWaterTemp": "Température de l'eau - mesures de température enregistrées pendant les plongées", "settings_dataSources_appleHealth_permissionManagedInHealth": "L'accès à HealthKit se gère dans l'app Santé", - "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit n'est pas disponible sur cet appareil" + "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit n'est pas disponible sur cet appareil", + "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", + "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", + "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", + "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", + "buddies_section_professionalRoles": "Rôles professionnels", + "buddies_roles_addRole": "Ajouter un rôle", + "buddies_roles_role": "Rôle", + "buddies_roles_agency": "Organisme", + "buddies_roles_credentialNumber": "Numéro de qualification", + "buddies_roles_removeTooltip": "Supprimer le rôle", + "buddies_roles_emptyHint": "Ajoutez les qualifications de moniteur ou de directeur de plongée pour les réutiliser lors de l'enregistrement des certifications et des cours.", + "buddies_detail_section_professionalRoles": "Rôles professionnels", + "certifications_detail_label_level": "Niveau", + "certifications_edit_hint_certificationName": "ex. Open Water Diver", + "certifications_edit_label_certificationName": "Nom de la certification *", + "certifications_edit_label_level": "Niveau", + "certifications_edit_level_notSpecified": "Non specifie", + "certifications_edit_validation_nameRequired": "Veuillez entrer un nom de certification", + "certifications_walletCard_countPlural": "{count} certifications", + "certifications_walletCard_countSingular": "{count} certification", + "certifications_walletCard_emptyFooter": "Ajoutez votre premiere certification", + "certifications_walletCard_error": "Echec du chargement des certifications", + "certifications_walletCard_semanticLabel": "Portefeuille de certifications. Appuyez pour voir toutes les certifications", + "certifications_walletCard_tapToAdd": "Appuie pour ajouter", + "certifications_walletCard_title": "Portefeuille de certifications", + "preDive_section_title": "Vérification pré-plongée", + "preDive_section_link": "Lier une session de checklist", + "preDive_section_unlink": "Dissocier", + "preDive_section_run": "Lancer la checklist pré-plongée", + "preDive_section_noUnlinked": "Aucune session de checklist non liée", + "diveDetailSection_preDiveChecklist_name": "Vérification pré-plongée", + "diveDetailSection_preDiveChecklist_description": "Session de checklist pré-plongée liée", + "diveCenters_summary_topRated": "Mieux notés", + "diveLog_instruments_customize": "Personnaliser les instruments", + "diveLog_instruments_customizeHint": "Activez ou désactivez les instruments. Faites glisser pour réorganiser.", + "enum_buddyRole_buddy": "Binome", + "enum_buddyRole_diveGuide": "Guide de plongee", + "enum_buddyRole_diveMaster": "Directeur de plongee", + "enum_buddyRole_instructor": "Moniteur", + "enum_buddyRole_solo": "Solo", + "enum_buddyRole_student": "Eleve", + "equipment_addSheet_brandHint": "ex. Scubapro", + "equipment_addSheet_brandLabel": "Marque", + "equipment_addSheet_closeTooltip": "Fermer", + "equipment_addSheet_currencyLabel": "Devise", + "equipment_addSheet_dateLabel": "Date", + "equipment_addSheet_errorSnackbar": "Erreur lors de l'ajout de l'equipement : {error}", + "equipment_addSheet_modelHint": "ex. MK25 EVO", + "equipment_addSheet_modelLabel": "Modele", + "equipment_addSheet_nameHint": "ex. Mon detendeur principal", + "equipment_addSheet_nameLabel": "Nom", + "equipment_addSheet_nameValidation": "Veuillez entrer un nom", + "equipment_addSheet_notesHint": "Notes supplementaires...", + "equipment_addSheet_notesLabel": "Notes", + "equipment_addSheet_priceLabel": "Prix", + "equipment_addSheet_purchaseInfoTitle": "Informations d'achat", + "equipment_addSheet_serialNumberLabel": "Numero de serie", + "equipment_addSheet_serviceIntervalHint": "ex. 365 pour annuel", + "equipment_addSheet_serviceIntervalLabel": "Intervalle de revision (jours)", + "equipment_addSheet_sizeHint": "ex. M, L, 42", + "equipment_addSheet_sizeLabel": "Taille", + "equipment_addSheet_submitButton": "Ajouter l'equipement", + "equipment_addSheet_successSnackbar": "Equipement ajoute avec succes", + "equipment_addSheet_title": "Ajouter un equipement", + "equipment_addSheet_typeLabel": "Type", + "media_diveMediaSection_unlinkDialogContent": "Retirer cette photo de la plongee ? La photo restera dans ta galerie.", + "media_diveMediaSection_unlinkDialogTitle": "Dissocier la photo", + "media_diveMediaSection_unlinkSuccess": "Photo dissociee", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 appareil se synchronise depuis une version plus récente de Submersion. Mettez à jour cet appareil pour recevoir ses derniers changements.} other{{count} appareils se synchronisent depuis une version plus récente de Submersion. Mettez à jour cet appareil pour recevoir leurs derniers changements.}}", + "settings_notifications_disabled_enableButton": "Activer", + "surfaceInterval_secondDive_gasAir": "(Air)", + "trips_detail_stat_totalBottomTime": "Temps au fond total", + "dashboard_photos_title": "Photos récentes", + "diveComputer_detail_cannotFilterNoSerial": "Filtrage impossible : aucun numero de serie pour cet ordinateur." } diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index ffba0782a9..bf94eea773 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "עומק מתחת למים - דגימות עומק שנרשמו במהלך צלילות", "settings_dataSources_appleHealth_dataTypeWaterTemp": "טמפרטורת מים - דגימות טמפרטורה שנרשמו במהלך צלילות", "settings_dataSources_appleHealth_permissionManagedInHealth": "הגישה ל-HealthKit מנוהלת באפליקציית הבריאות", - "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit אינו זמין במכשיר הזה" + "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit אינו זמין במכשיר הזה", + "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} הסמכות", + "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", + "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": "סיסמה", + "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 שלך", + "buddies_section_professionalRoles": "תפקידים מקצועיים", + "buddies_roles_addRole": "הוסף תפקיד", + "buddies_roles_role": "תפקיד", + "buddies_roles_agency": "גוף הסמכה", + "buddies_roles_credentialNumber": "מספר הסמכה", + "buddies_roles_removeTooltip": "הסר תפקיד", + "buddies_roles_emptyHint": "הוסף הסמכות מדריך או דייבמאסטר לשימוש חוזר בעת רישום הסמכות וקורסים.", + "buddies_detail_section_professionalRoles": "תפקידים מקצועיים", + "certifications_detail_label_level": "רמה", + "certifications_edit_hint_certificationName": "לדוגמה, Open Water Diver", + "certifications_edit_label_certificationName": "שם הסמכה *", + "certifications_edit_label_level": "רמה", + "certifications_edit_level_notSpecified": "לא צוין", + "certifications_edit_validation_nameRequired": "נא להזין שם הסמכה", + "certifications_walletCard_countPlural": "{count} הסמכות", + "certifications_walletCard_countSingular": "הסמכה {count}", + "certifications_walletCard_emptyFooter": "הוסף את ההסמכה הראשונה שלך", + "certifications_walletCard_error": "טעינת ההסמכות נכשלה", + "certifications_walletCard_semanticLabel": "ארנק הסמכות. הקש כדי לצפות בכל ההסמכות", + "certifications_walletCard_tapToAdd": "הקש להוספה", + "certifications_walletCard_title": "ארנק הסמכות", + "preDive_section_title": "בדיקה לפני צלילה", + "preDive_section_link": "קשר הרצת רשימת בדיקה", + "preDive_section_unlink": "בטל קישור", + "preDive_section_run": "הרץ רשימת בדיקה לפני צלילה", + "preDive_section_noUnlinked": "אין הרצות רשימת בדיקה לא מקושרות", + "diveDetailSection_preDiveChecklist_name": "בדיקה לפני צלילה", + "diveDetailSection_preDiveChecklist_description": "הרצת רשימת בדיקה לפני צלילה מקושרת", + "diveCenters_summary_topRated": "מדורג ביותר", + "diveLog_instruments_customize": "התאמה אישית של מכשירים", + "diveLog_instruments_customizeHint": "הפעל או כבה מכשירים. גרור כדי לסדר מחדש.", + "enum_buddyRole_buddy": "שותף", + "enum_buddyRole_diveGuide": "מדריך צלילה", + "enum_buddyRole_diveMaster": "דייבמאסטר", + "enum_buddyRole_instructor": "מדריך", + "enum_buddyRole_solo": "יחיד", + "enum_buddyRole_student": "תלמיד", + "equipment_addSheet_brandHint": "לדוגמה, Scubapro", + "equipment_addSheet_brandLabel": "מותג", + "equipment_addSheet_closeTooltip": "סגור", + "equipment_addSheet_currencyLabel": "מטבע", + "equipment_addSheet_dateLabel": "תאריך", + "equipment_addSheet_errorSnackbar": "שגיאה בהוספת ציוד: {error}", + "equipment_addSheet_modelHint": "לדוגמה, MK25 EVO", + "equipment_addSheet_modelLabel": "דגם", + "equipment_addSheet_nameHint": "לדוגמה, הרגולטור הראשי שלי", + "equipment_addSheet_nameLabel": "שם", + "equipment_addSheet_nameValidation": "נא להזין שם", + "equipment_addSheet_notesHint": "הערות נוספות...", + "equipment_addSheet_notesLabel": "הערות", + "equipment_addSheet_priceLabel": "מחיר", + "equipment_addSheet_purchaseInfoTitle": "פרטי רכישה", + "equipment_addSheet_serialNumberLabel": "מספר סידורי", + "equipment_addSheet_serviceIntervalHint": "לדוגמה, 365 לשנתי", + "equipment_addSheet_serviceIntervalLabel": "מרווח טיפול (ימים)", + "equipment_addSheet_sizeHint": "לדוגמה, M, L, 42", + "equipment_addSheet_sizeLabel": "מידה", + "equipment_addSheet_submitButton": "הוסף ציוד", + "equipment_addSheet_successSnackbar": "הציוד נוסף בהצלחה", + "equipment_addSheet_title": "הוסף ציוד", + "equipment_addSheet_typeLabel": "סוג", + "media_diveMediaSection_unlinkDialogContent": "להסיר תמונה זו מהצלילה? התמונה תישאר בגלריה שלך.", + "media_diveMediaSection_unlinkDialogTitle": "ביטול קישור תמונה", + "media_diveMediaSection_unlinkSuccess": "קישור התמונה בוטל", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{מכשיר אחד מסתנכרן מגרסה חדשה יותר של Submersion. עדכן מכשיר זה כדי לקבל את השינויים האחרונים שלו.} other{{count} מכשירים מסתנכרנים מגרסה חדשה יותר של Submersion. עדכן מכשיר זה כדי לקבל את השינויים האחרונים שלהם.}}", + "settings_notifications_disabled_enableButton": "אפשר", + "surfaceInterval_secondDive_gasAir": "(אוויר)", + "trips_detail_stat_totalBottomTime": "סה\"כ זמן תחתית", + "dashboard_photos_title": "תמונות אחרונות", + "diveComputer_detail_cannotFilterNoSerial": "לא ניתן לסנן: אין מספר סידורי למחשב זה." } diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index a122d6b4b3..12fd29d4b2 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "Vízmélység - a merülések során rögzített mélységadatok", "settings_dataSources_appleHealth_dataTypeWaterTemp": "Vízhőmérséklet - a merülések során rögzített hőmérsékleti adatok", "settings_dataSources_appleHealth_permissionManagedInHealth": "A HealthKit hozzáférést a Health alkalmazásban kezelheted", - "settings_dataSources_appleHealth_permissionUnsupported": "A HealthKit nem érhető el ezen az eszközön" + "settings_dataSources_appleHealth_permissionUnsupported": "A HealthKit nem érhető el ezen az eszközön", + "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", + "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", + "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ó", + "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", + "buddies_section_professionalRoles": "Szakmai szerepek", + "buddies_roles_addRole": "Szerep hozzáadása", + "buddies_roles_role": "Szerep", + "buddies_roles_agency": "Szervezet", + "buddies_roles_credentialNumber": "Igazolványszám", + "buddies_roles_removeTooltip": "Szerep eltávolítása", + "buddies_roles_emptyHint": "Adja hozzá az oktatói vagy divemaster képesítéseket, hogy újra felhasználhassa őket képesítések és tanfolyamok rögzítésekor.", + "buddies_detail_section_professionalRoles": "Szakmai szerepek", + "certifications_detail_label_level": "Szint", + "certifications_edit_hint_certificationName": "pl. Open Water Diver", + "certifications_edit_label_certificationName": "Kepesites neve *", + "certifications_edit_label_level": "Szint", + "certifications_edit_level_notSpecified": "Nincs megadva", + "certifications_edit_validation_nameRequired": "Kerem, adja meg a tanusitvany nevet", + "certifications_walletCard_countPlural": "{count} tanusitvany", + "certifications_walletCard_countSingular": "{count} tanusitvany", + "certifications_walletCard_emptyFooter": "Adja hozza az elso tanusitvanyt", + "certifications_walletCard_error": "Nem sikerult a tanusitványok betoltese", + "certifications_walletCard_semanticLabel": "Tanusitvany tarca. Koppintson az osszes tanusitvany megtekintésehez", + "certifications_walletCard_tapToAdd": "Koppintson a hozzaadashoz", + "certifications_walletCard_title": "Tanusitvany tarca", + "preDive_section_title": "Merülés előtti ellenőrzés", + "preDive_section_link": "Ellenőrzőlista-munkamenet csatolása", + "preDive_section_unlink": "Csatolás megszüntetése", + "preDive_section_run": "Merülés előtti ellenőrzőlista futtatása", + "preDive_section_noUnlinked": "Nincsenek nem csatolt ellenőrzőlista-munkamenetek", + "diveDetailSection_preDiveChecklist_name": "Merülés előtti ellenőrzés", + "diveDetailSection_preDiveChecklist_description": "Kapcsolt merülés előtti ellenőrzőlista-munkamenet", + "diveCenters_summary_topRated": "Legjobbra értékelt", + "diveLog_instruments_customize": "Műszerek testreszabása", + "diveLog_instruments_customizeHint": "Kapcsolja be vagy ki a műszereket. Húzza az átrendezéshez.", + "enum_buddyRole_buddy": "Buddy", + "enum_buddyRole_diveGuide": "Merulesvezeto", + "enum_buddyRole_diveMaster": "Divemaster", + "enum_buddyRole_instructor": "Oktato", + "enum_buddyRole_solo": "Solo", + "enum_buddyRole_student": "Tanulo", + "equipment_addSheet_brandHint": "pl. Scubapro", + "equipment_addSheet_brandLabel": "Marka", + "equipment_addSheet_closeTooltip": "Bezaras", + "equipment_addSheet_currencyLabel": "Penznem", + "equipment_addSheet_dateLabel": "Datum", + "equipment_addSheet_errorSnackbar": "Hiba a felszereles hozzaadasakor: {error}", + "equipment_addSheet_modelHint": "pl. MK25 EVO", + "equipment_addSheet_modelLabel": "Modell", + "equipment_addSheet_nameHint": "pl. Elsooleges automata", + "equipment_addSheet_nameLabel": "Nev", + "equipment_addSheet_nameValidation": "Kerem adjon meg egy nevet", + "equipment_addSheet_notesHint": "Tovabbl megjegyzesek...", + "equipment_addSheet_notesLabel": "Megjegyzesek", + "equipment_addSheet_priceLabel": "Ar", + "equipment_addSheet_purchaseInfoTitle": "Vasarlasi informaciok", + "equipment_addSheet_serialNumberLabel": "Sorozatszam", + "equipment_addSheet_serviceIntervalHint": "pl. 365 az eves szervizhez", + "equipment_addSheet_serviceIntervalLabel": "Szerviz intervallum (nap)", + "equipment_addSheet_sizeHint": "pl. M, L, 42", + "equipment_addSheet_sizeLabel": "Meret", + "equipment_addSheet_submitButton": "Felszereles hozzaadasa", + "equipment_addSheet_successSnackbar": "Felszereles sikeresen hozzaadva", + "equipment_addSheet_title": "Felszereles hozzaadasa", + "equipment_addSheet_typeLabel": "Tipus", + "media_diveMediaSection_unlinkDialogContent": "Eltavolitja ezt a fotot a merülesrol? A foto megmarad a galeriadjaban.", + "media_diveMediaSection_unlinkDialogTitle": "Foto levalasztasa", + "media_diveMediaSection_unlinkSuccess": "Foto levalasztva", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 eszköz a Submersion újabb verziójából szinkronizál. Frissítsd ezt az eszközt, hogy megkapd a legújabb változtatásait.} other{{count} eszköz a Submersion újabb verziójából szinkronizál. Frissítsd ezt az eszközt, hogy megkapd a legújabb változtatásaikat.}}", + "settings_notifications_disabled_enableButton": "Engedelyezes", + "surfaceInterval_secondDive_gasAir": "(Levegő)", + "trips_detail_stat_totalBottomTime": "Osszes fenekido", + "dashboard_photos_title": "Legutóbbi fotók", + "diveComputer_detail_cannotFilterNoSerial": "Nem lehet szurni: nincs sorozatszam ehhez a szamitogephez." } diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 1a272bc329..81849f03c2 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "Profondità subacquea - campioni di profondità registrati durante le immersioni", "settings_dataSources_appleHealth_dataTypeWaterTemp": "Temperatura dell'acqua - campioni di temperatura registrati durante le immersioni", "settings_dataSources_appleHealth_permissionManagedInHealth": "L'accesso a HealthKit si gestisce nell'app Salute", - "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit non è disponibile su questo dispositivo" + "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit non è disponibile su questo dispositivo", + "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", + "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", + "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", + "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", + "buddies_section_professionalRoles": "Ruoli Professionali", + "buddies_roles_addRole": "Aggiungi ruolo", + "buddies_roles_role": "Ruolo", + "buddies_roles_agency": "Agenzia", + "buddies_roles_credentialNumber": "Numero di credenziale", + "buddies_roles_removeTooltip": "Rimuovi ruolo", + "buddies_roles_emptyHint": "Aggiungi le credenziali di istruttore o divemaster per riutilizzarle durante la registrazione di certificazioni e corsi.", + "buddies_detail_section_professionalRoles": "Ruoli Professionali", + "certifications_detail_label_level": "Livello", + "certifications_edit_hint_certificationName": "es. Open Water Diver", + "certifications_edit_label_certificationName": "Nome certificazione *", + "certifications_edit_label_level": "Livello", + "certifications_edit_level_notSpecified": "Non specificato", + "certifications_edit_validation_nameRequired": "Inserisci un nome per la certificazione", + "certifications_walletCard_countPlural": "{count} certificazioni", + "certifications_walletCard_countSingular": "{count} certificazione", + "certifications_walletCard_emptyFooter": "Aggiungi la tua prima certificazione", + "certifications_walletCard_error": "Impossibile caricare le certificazioni", + "certifications_walletCard_semanticLabel": "Portafoglio certificazioni. Tocca per visualizzare tutte le certificazioni", + "certifications_walletCard_tapToAdd": "Tocca per aggiungere", + "certifications_walletCard_title": "Portafoglio certificazioni", + "preDive_section_title": "Controllo pre-immersione", + "preDive_section_link": "Collega una sessione di checklist", + "preDive_section_unlink": "Scollega", + "preDive_section_run": "Esegui checklist pre-immersione", + "preDive_section_noUnlinked": "Nessuna sessione di checklist non collegata", + "diveDetailSection_preDiveChecklist_name": "Controllo pre-immersione", + "diveDetailSection_preDiveChecklist_description": "Sessione di checklist pre-immersione collegata", + "diveCenters_summary_topRated": "Più Votati", + "diveLog_instruments_customize": "Personalizza strumenti", + "diveLog_instruments_customizeHint": "Attiva o disattiva gli strumenti. Trascina per riordinare.", + "enum_buddyRole_buddy": "Compagno", + "enum_buddyRole_diveGuide": "Guida subacquea", + "enum_buddyRole_diveMaster": "Divemaster", + "enum_buddyRole_instructor": "Istruttore", + "enum_buddyRole_solo": "Solitario", + "enum_buddyRole_student": "Allievo", + "equipment_addSheet_brandHint": "es. Scubapro", + "equipment_addSheet_brandLabel": "Marca", + "equipment_addSheet_closeTooltip": "Chiudi", + "equipment_addSheet_currencyLabel": "Valuta", + "equipment_addSheet_dateLabel": "Data", + "equipment_addSheet_errorSnackbar": "Errore nell'aggiunta dell'attrezzatura: {error}", + "equipment_addSheet_modelHint": "es. MK25 EVO", + "equipment_addSheet_modelLabel": "Modello", + "equipment_addSheet_nameHint": "es. Il mio erogatore principale", + "equipment_addSheet_nameLabel": "Nome", + "equipment_addSheet_nameValidation": "Inserisci un nome", + "equipment_addSheet_notesHint": "Note aggiuntive...", + "equipment_addSheet_notesLabel": "Note", + "equipment_addSheet_priceLabel": "Prezzo", + "equipment_addSheet_purchaseInfoTitle": "Informazioni acquisto", + "equipment_addSheet_serialNumberLabel": "Numero di serie", + "equipment_addSheet_serviceIntervalHint": "es. 365 per annuale", + "equipment_addSheet_serviceIntervalLabel": "Intervallo manutenzione (giorni)", + "equipment_addSheet_sizeHint": "es. M, L, 42", + "equipment_addSheet_sizeLabel": "Taglia", + "equipment_addSheet_submitButton": "Aggiungi attrezzatura", + "equipment_addSheet_successSnackbar": "Attrezzatura aggiunta con successo", + "equipment_addSheet_title": "Aggiungi attrezzatura", + "equipment_addSheet_typeLabel": "Tipo", + "media_diveMediaSection_unlinkDialogContent": "Rimuovere questa foto dall'immersione? La foto rimarrà nella tua galleria.", + "media_diveMediaSection_unlinkDialogTitle": "Scollega foto", + "media_diveMediaSection_unlinkSuccess": "Foto scollegata", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 dispositivo si sincronizza da una versione più recente di Submersion. Aggiorna questo dispositivo per ricevere le sue ultime modifiche.} other{{count} dispositivi si sincronizzano da una versione più recente di Submersion. Aggiorna questo dispositivo per ricevere le loro ultime modifiche.}}", + "settings_notifications_disabled_enableButton": "Abilita", + "surfaceInterval_secondDive_gasAir": "(Aria)", + "trips_detail_stat_totalBottomTime": "Tempo di fondo totale", + "dashboard_photos_title": "Foto recenti", + "diveComputer_detail_cannotFilterNoSerial": "Impossibile filtrare: nessun numero di serie per questo computer." } diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index edaf2fa2d4..29d3e6bc5b 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -55861,6 +55861,678 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'HealthKit is not available on this device'** String get settings_dataSources_appleHealth_permissionUnsupported; + + /// 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: + /// **'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: + /// **'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: + /// **'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 @buddies_section_professionalRoles. + /// + /// In en, this message translates to: + /// **'Professional Roles'** + String get buddies_section_professionalRoles; + + /// No description provided for @buddies_roles_addRole. + /// + /// In en, this message translates to: + /// **'Add role'** + String get buddies_roles_addRole; + + /// No description provided for @buddies_roles_role. + /// + /// In en, this message translates to: + /// **'Role'** + String get buddies_roles_role; + + /// No description provided for @buddies_roles_agency. + /// + /// In en, this message translates to: + /// **'Agency'** + String get buddies_roles_agency; + + /// No description provided for @buddies_roles_credentialNumber. + /// + /// In en, this message translates to: + /// **'Credential number'** + String get buddies_roles_credentialNumber; + + /// No description provided for @buddies_roles_removeTooltip. + /// + /// In en, this message translates to: + /// **'Remove role'** + String get buddies_roles_removeTooltip; + + /// No description provided for @buddies_roles_emptyHint. + /// + /// In en, this message translates to: + /// **'Add instructor or divemaster credentials to reuse them when logging certifications and courses.'** + String get buddies_roles_emptyHint; + + /// No description provided for @buddies_detail_section_professionalRoles. + /// + /// In en, this message translates to: + /// **'Professional Roles'** + String get buddies_detail_section_professionalRoles; + + /// No description provided for @certifications_detail_label_level. + /// + /// In en, this message translates to: + /// **'Level'** + String get certifications_detail_label_level; + + /// No description provided for @certifications_edit_hint_certificationName. + /// + /// In en, this message translates to: + /// **'e.g., Open Water Diver'** + String get certifications_edit_hint_certificationName; + + /// No description provided for @certifications_edit_label_certificationName. + /// + /// In en, this message translates to: + /// **'Certification Name *'** + String get certifications_edit_label_certificationName; + + /// No description provided for @certifications_edit_label_level. + /// + /// In en, this message translates to: + /// **'Level'** + String get certifications_edit_label_level; + + /// No description provided for @certifications_edit_level_notSpecified. + /// + /// In en, this message translates to: + /// **'Not specified'** + String get certifications_edit_level_notSpecified; + + /// No description provided for @certifications_edit_validation_nameRequired. + /// + /// In en, this message translates to: + /// **'Please enter a certification name'** + String get certifications_edit_validation_nameRequired; + + /// No description provided for @certifications_walletCard_countPlural. + /// + /// In en, this message translates to: + /// **'{count} certifications'** + String certifications_walletCard_countPlural(Object count); + + /// No description provided for @certifications_walletCard_countSingular. + /// + /// In en, this message translates to: + /// **'{count} certification'** + String certifications_walletCard_countSingular(Object count); + + /// No description provided for @certifications_walletCard_emptyFooter. + /// + /// In en, this message translates to: + /// **'Add your first certification'** + String get certifications_walletCard_emptyFooter; + + /// No description provided for @certifications_walletCard_error. + /// + /// In en, this message translates to: + /// **'Failed to load certifications'** + String get certifications_walletCard_error; + + /// No description provided for @certifications_walletCard_semanticLabel. + /// + /// In en, this message translates to: + /// **'Certification Wallet. Tap to view all certifications'** + String get certifications_walletCard_semanticLabel; + + /// No description provided for @certifications_walletCard_tapToAdd. + /// + /// In en, this message translates to: + /// **'Tap to add'** + String get certifications_walletCard_tapToAdd; + + /// No description provided for @certifications_walletCard_title. + /// + /// In en, this message translates to: + /// **'Certification Wallet'** + String get certifications_walletCard_title; + + /// No description provided for @preDive_section_title. + /// + /// In en, this message translates to: + /// **'Pre-Dive Check'** + String get preDive_section_title; + + /// No description provided for @preDive_section_link. + /// + /// In en, this message translates to: + /// **'Link a checklist session'** + String get preDive_section_link; + + /// No description provided for @preDive_section_unlink. + /// + /// In en, this message translates to: + /// **'Unlink'** + String get preDive_section_unlink; + + /// No description provided for @preDive_section_run. + /// + /// In en, this message translates to: + /// **'Run pre-dive checklist'** + String get preDive_section_run; + + /// No description provided for @preDive_section_noUnlinked. + /// + /// In en, this message translates to: + /// **'No unlinked checklist sessions'** + String get preDive_section_noUnlinked; + + /// No description provided for @diveDetailSection_preDiveChecklist_name. + /// + /// In en, this message translates to: + /// **'Pre-Dive Check'** + String get diveDetailSection_preDiveChecklist_name; + + /// No description provided for @diveDetailSection_preDiveChecklist_description. + /// + /// In en, this message translates to: + /// **'Linked pre-dive checklist session'** + String get diveDetailSection_preDiveChecklist_description; + + /// No description provided for @dashboard_photos_title. + /// + /// In en, this message translates to: + /// **'Recent photos'** + String get dashboard_photos_title; + + /// No description provided for @diveCenters_summary_topRated. + /// + /// In en, this message translates to: + /// **'Top Rated'** + String get diveCenters_summary_topRated; + + /// No description provided for @diveLog_instruments_customize. + /// + /// In en, this message translates to: + /// **'Customize instruments'** + String get diveLog_instruments_customize; + + /// No description provided for @diveLog_instruments_customizeHint. + /// + /// In en, this message translates to: + /// **'Toggle instruments on or off. Drag to reorder.'** + String get diveLog_instruments_customizeHint; + + /// No description provided for @enum_buddyRole_buddy. + /// + /// In en, this message translates to: + /// **'Buddy'** + String get enum_buddyRole_buddy; + + /// No description provided for @enum_buddyRole_diveGuide. + /// + /// In en, this message translates to: + /// **'Dive Guide'** + String get enum_buddyRole_diveGuide; + + /// No description provided for @enum_buddyRole_diveMaster. + /// + /// In en, this message translates to: + /// **'Divemaster'** + String get enum_buddyRole_diveMaster; + + /// No description provided for @enum_buddyRole_instructor. + /// + /// In en, this message translates to: + /// **'Instructor'** + String get enum_buddyRole_instructor; + + /// No description provided for @enum_buddyRole_solo. + /// + /// In en, this message translates to: + /// **'Solo'** + String get enum_buddyRole_solo; + + /// No description provided for @enum_buddyRole_student. + /// + /// In en, this message translates to: + /// **'Student'** + String get enum_buddyRole_student; + + /// No description provided for @equipment_addSheet_brandHint. + /// + /// In en, this message translates to: + /// **'e.g., Scubapro'** + String get equipment_addSheet_brandHint; + + /// No description provided for @equipment_addSheet_brandLabel. + /// + /// In en, this message translates to: + /// **'Brand'** + String get equipment_addSheet_brandLabel; + + /// No description provided for @equipment_addSheet_closeTooltip. + /// + /// In en, this message translates to: + /// **'Close'** + String get equipment_addSheet_closeTooltip; + + /// No description provided for @equipment_addSheet_currencyLabel. + /// + /// In en, this message translates to: + /// **'Currency'** + String get equipment_addSheet_currencyLabel; + + /// No description provided for @equipment_addSheet_dateLabel. + /// + /// In en, this message translates to: + /// **'Date'** + String get equipment_addSheet_dateLabel; + + /// No description provided for @equipment_addSheet_errorSnackbar. + /// + /// In en, this message translates to: + /// **'Error adding equipment: {error}'** + String equipment_addSheet_errorSnackbar(Object error); + + /// No description provided for @equipment_addSheet_modelHint. + /// + /// In en, this message translates to: + /// **'e.g., MK25 EVO'** + String get equipment_addSheet_modelHint; + + /// No description provided for @equipment_addSheet_modelLabel. + /// + /// In en, this message translates to: + /// **'Model'** + String get equipment_addSheet_modelLabel; + + /// No description provided for @equipment_addSheet_nameHint. + /// + /// In en, this message translates to: + /// **'e.g., My Primary Regulator'** + String get equipment_addSheet_nameHint; + + /// No description provided for @equipment_addSheet_nameLabel. + /// + /// In en, this message translates to: + /// **'Name'** + String get equipment_addSheet_nameLabel; + + /// No description provided for @equipment_addSheet_nameValidation. + /// + /// In en, this message translates to: + /// **'Please enter a name'** + String get equipment_addSheet_nameValidation; + + /// No description provided for @equipment_addSheet_notesHint. + /// + /// In en, this message translates to: + /// **'Additional notes...'** + String get equipment_addSheet_notesHint; + + /// No description provided for @equipment_addSheet_notesLabel. + /// + /// In en, this message translates to: + /// **'Notes'** + String get equipment_addSheet_notesLabel; + + /// No description provided for @equipment_addSheet_priceLabel. + /// + /// In en, this message translates to: + /// **'Price'** + String get equipment_addSheet_priceLabel; + + /// No description provided for @equipment_addSheet_purchaseInfoTitle. + /// + /// In en, this message translates to: + /// **'Purchase Information'** + String get equipment_addSheet_purchaseInfoTitle; + + /// No description provided for @equipment_addSheet_serialNumberLabel. + /// + /// In en, this message translates to: + /// **'Serial Number'** + String get equipment_addSheet_serialNumberLabel; + + /// No description provided for @equipment_addSheet_serviceIntervalHint. + /// + /// In en, this message translates to: + /// **'e.g., 365 for yearly'** + String get equipment_addSheet_serviceIntervalHint; + + /// No description provided for @equipment_addSheet_serviceIntervalLabel. + /// + /// In en, this message translates to: + /// **'Service Interval (days)'** + String get equipment_addSheet_serviceIntervalLabel; + + /// No description provided for @equipment_addSheet_sizeHint. + /// + /// In en, this message translates to: + /// **'e.g., M, L, 42'** + String get equipment_addSheet_sizeHint; + + /// No description provided for @equipment_addSheet_sizeLabel. + /// + /// In en, this message translates to: + /// **'Size'** + String get equipment_addSheet_sizeLabel; + + /// No description provided for @equipment_addSheet_submitButton. + /// + /// In en, this message translates to: + /// **'Add Equipment'** + String get equipment_addSheet_submitButton; + + /// No description provided for @equipment_addSheet_successSnackbar. + /// + /// In en, this message translates to: + /// **'Equipment added successfully'** + String get equipment_addSheet_successSnackbar; + + /// No description provided for @equipment_addSheet_title. + /// + /// In en, this message translates to: + /// **'Add Equipment'** + String get equipment_addSheet_title; + + /// No description provided for @equipment_addSheet_typeLabel. + /// + /// In en, this message translates to: + /// **'Type'** + String get equipment_addSheet_typeLabel; + + /// No description provided for @media_diveMediaSection_unlinkDialogContent. + /// + /// In en, this message translates to: + /// **'Remove this photo from the dive? The photo will remain in your gallery.'** + String get media_diveMediaSection_unlinkDialogContent; + + /// No description provided for @media_diveMediaSection_unlinkDialogTitle. + /// + /// In en, this message translates to: + /// **'Unlink Photo'** + String get media_diveMediaSection_unlinkDialogTitle; + + /// No description provided for @media_diveMediaSection_unlinkSuccess. + /// + /// In en, this message translates to: + /// **'Photo unlinked'** + String get media_diveMediaSection_unlinkSuccess; + + /// No description provided for @settings_cloudSync_peerRequiresUpdate_banner. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 device syncs from a newer version of Submersion. Update this device to receive its latest changes.} other{{count} devices sync from a newer version of Submersion. Update this device to receive their latest changes.}}'** + String settings_cloudSync_peerRequiresUpdate_banner(num count); + + /// No description provided for @settings_notifications_disabled_enableButton. + /// + /// In en, this message translates to: + /// **'Enable'** + String get settings_notifications_disabled_enableButton; + + /// Label indicating the second dive uses air + /// + /// In en, this message translates to: + /// **'(Air)'** + String get surfaceInterval_secondDive_gasAir; + + /// No description provided for @trips_detail_stat_totalBottomTime. + /// + /// In en, this message translates to: + /// **'Total Bottom Time'** + String get trips_detail_stat_totalBottomTime; + + /// No description provided for @diveComputer_detail_cannotFilterNoSerial. + /// + /// In en, this message translates to: + /// **'Cannot filter: no serial number for this computer.'** + String get diveComputer_detail_cannotFilterNoSerial; } class _AppLocalizationsDelegate diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index f75c4098a7..16b577d155 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -33629,4 +33629,405 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit غير متوفر على هذا الجهاز'; + + @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 => 'المعدات والشهادات'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'الأدوار المهنية'; + + @override + String get buddies_roles_addRole => 'إضافة دور'; + + @override + String get buddies_roles_role => 'الدور'; + + @override + String get buddies_roles_agency => 'الجهة'; + + @override + String get buddies_roles_credentialNumber => 'رقم الاعتماد'; + + @override + String get buddies_roles_removeTooltip => 'إزالة الدور'; + + @override + String get buddies_roles_emptyHint => + 'أضف بيانات اعتماد المدرب أو مدرب الغوص الرئيسي لإعادة استخدامها عند تسجيل الشهادات والدورات.'; + + @override + String get buddies_detail_section_professionalRoles => 'الأدوار المهنية'; + + @override + String get certifications_detail_label_level => 'المستوى'; + + @override + String get certifications_edit_hint_certificationName => + 'مثال: غواص مياه مفتوحة'; + + @override + String get certifications_edit_label_certificationName => 'اسم الشهادة *'; + + @override + String get certifications_edit_label_level => 'المستوى'; + + @override + String get certifications_edit_level_notSpecified => 'غير محدد'; + + @override + String get certifications_edit_validation_nameRequired => + 'يرجى إدخال اسم الشهادة'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count شهادات'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count شهادة'; + } + + @override + String get certifications_walletCard_emptyFooter => 'أضف شهادتك الأولى'; + + @override + String get certifications_walletCard_error => 'فشل في تحميل الشهادات'; + + @override + String get certifications_walletCard_semanticLabel => + 'محفظة الشهادات. انقر لعرض جميع الشهادات'; + + @override + String get certifications_walletCard_tapToAdd => 'انقر للإضافة'; + + @override + String get certifications_walletCard_title => 'محفظة الشهادات'; + + @override + String get preDive_section_title => 'فحص ما قبل الغوص'; + + @override + String get preDive_section_link => 'ربط جلسة قائمة تحقق'; + + @override + String get preDive_section_unlink => 'إلغاء الربط'; + + @override + String get preDive_section_run => 'تشغيل قائمة تحقق ما قبل الغوص'; + + @override + String get preDive_section_noUnlinked => + 'لا توجد جلسات قوائم تحقق غير مرتبطة'; + + @override + String get diveDetailSection_preDiveChecklist_name => 'فحص ما قبل الغوص'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'جلسة قائمة تحقق ما قبل الغوص المرتبطة'; + + @override + String get dashboard_photos_title => 'أحدث الصور'; + + @override + String get diveCenters_summary_topRated => 'الأعلى تقييماً'; + + @override + String get diveLog_instruments_customize => 'تخصيص الأدوات'; + + @override + String get diveLog_instruments_customizeHint => + 'قم بتشغيل الأدوات أو إيقافها. اسحب لإعادة الترتيب.'; + + @override + String get enum_buddyRole_buddy => 'زميل غوص'; + + @override + String get enum_buddyRole_diveGuide => 'مرشد غوص'; + + @override + String get enum_buddyRole_diveMaster => 'مدرب غوص رئيسي'; + + @override + String get enum_buddyRole_instructor => 'مدرب'; + + @override + String get enum_buddyRole_solo => 'منفرد'; + + @override + String get enum_buddyRole_student => 'طالب'; + + @override + String get equipment_addSheet_brandHint => 'مثال: Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'العلامة التجارية'; + + @override + String get equipment_addSheet_closeTooltip => 'إغلاق'; + + @override + String get equipment_addSheet_currencyLabel => 'العملة'; + + @override + String get equipment_addSheet_dateLabel => 'التاريخ'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'خطأ في إضافة المعدات: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'مثال: MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'الطراز'; + + @override + String get equipment_addSheet_nameHint => 'مثال: منظم الغوص الرئيسي'; + + @override + String get equipment_addSheet_nameLabel => 'الاسم'; + + @override + String get equipment_addSheet_nameValidation => 'يرجى إدخال اسم'; + + @override + String get equipment_addSheet_notesHint => 'ملاحظات إضافية...'; + + @override + String get equipment_addSheet_notesLabel => 'ملاحظات'; + + @override + String get equipment_addSheet_priceLabel => 'السعر'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'معلومات الشراء'; + + @override + String get equipment_addSheet_serialNumberLabel => 'الرقم التسلسلي'; + + @override + String get equipment_addSheet_serviceIntervalHint => + 'مثال: 365 للصيانة السنوية'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'فترة الصيانة (بالأيام)'; + + @override + String get equipment_addSheet_sizeHint => 'مثال: M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'المقاس'; + + @override + String get equipment_addSheet_submitButton => 'إضافة معدات'; + + @override + String get equipment_addSheet_successSnackbar => 'تمت إضافة المعدات بنجاح'; + + @override + String get equipment_addSheet_title => 'إضافة معدات'; + + @override + String get equipment_addSheet_typeLabel => 'النوع'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'هل تريد إزالة هذه الصورة من الغوصة؟ ستبقى الصورة في معرض الصور.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'إلغاء ربط الصورة'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'تم إلغاء ربط الصورة'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count أجهزة تتزامن من إصدار أحدث من Submersion. حدّث هذا الجهاز لتلقي أحدث تغييراتها.', + one: + 'جهاز واحد يتزامن من إصدار أحدث من Submersion. حدّث هذا الجهاز لتلقي أحدث تغييراته.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'تمكين'; + + @override + String get surfaceInterval_secondDive_gasAir => '(هواء)'; + + @override + String get trips_detail_stat_totalBottomTime => 'إجمالي وقت القاع'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'لا يمكن التصفية: لا يوجد رقم تسلسلي لهذا الكمبيوتر.'; } diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index e4920d8892..4f58bf10d3 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -33894,4 +33894,415 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit ist auf diesem Gerät nicht verfügbar'; + + @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'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'Berufliche Rollen'; + + @override + String get buddies_roles_addRole => 'Rolle hinzufügen'; + + @override + String get buddies_roles_role => 'Rolle'; + + @override + String get buddies_roles_agency => 'Organisation'; + + @override + String get buddies_roles_credentialNumber => 'Zertifizierungsnummer'; + + @override + String get buddies_roles_removeTooltip => 'Rolle entfernen'; + + @override + String get buddies_roles_emptyHint => + 'Fügen Sie Tauchlehrer- oder Divemaster-Qualifikationen hinzu, um sie beim Erfassen von Zertifizierungen und Kursen wiederzuverwenden.'; + + @override + String get buddies_detail_section_professionalRoles => 'Berufliche Rollen'; + + @override + String get certifications_detail_label_level => 'Stufe'; + + @override + String get certifications_edit_hint_certificationName => + 'z. B. Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => + 'Zertifizierungsname *'; + + @override + String get certifications_edit_label_level => 'Stufe'; + + @override + String get certifications_edit_level_notSpecified => 'Nicht angegeben'; + + @override + String get certifications_edit_validation_nameRequired => + 'Bitte geben Sie einen Zertifizierungsnamen ein'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count Zertifizierungen'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count Zertifizierung'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Fügen Sie Ihre erste Zertifizierung hinzu'; + + @override + String get certifications_walletCard_error => + 'Zertifizierungen konnten nicht geladen werden'; + + @override + String get certifications_walletCard_semanticLabel => + 'Zertifizierungskartei. Tippen, um alle Zertifizierungen anzuzeigen'; + + @override + String get certifications_walletCard_tapToAdd => 'Tippen zum Hinzufügen'; + + @override + String get certifications_walletCard_title => 'Zertifizierungskartei'; + + @override + String get preDive_section_title => 'Check vor dem Tauchgang'; + + @override + String get preDive_section_link => 'Checklisten-Durchlauf verknüpfen'; + + @override + String get preDive_section_unlink => 'Verknüpfung aufheben'; + + @override + String get preDive_section_run => 'Checkliste vor dem Tauchgang durchführen'; + + @override + String get preDive_section_noUnlinked => + 'Keine unverknüpften Checklisten-Durchläufe'; + + @override + String get diveDetailSection_preDiveChecklist_name => + 'Check vor dem Tauchgang'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Verknüpfter Checklisten-Durchlauf vor dem Tauchgang'; + + @override + String get dashboard_photos_title => 'Aktuelle Fotos'; + + @override + String get diveCenters_summary_topRated => 'Bestbewertet'; + + @override + String get diveLog_instruments_customize => 'Instrumente anpassen'; + + @override + String get diveLog_instruments_customizeHint => + 'Instrumente ein- oder ausschalten. Zum Sortieren ziehen.'; + + @override + String get enum_buddyRole_buddy => 'Tauchpartner'; + + @override + String get enum_buddyRole_diveGuide => 'Tauchguide'; + + @override + String get enum_buddyRole_diveMaster => 'Divemaster'; + + @override + String get enum_buddyRole_instructor => 'Tauchlehrer'; + + @override + String get enum_buddyRole_solo => 'Solo'; + + @override + String get enum_buddyRole_student => 'Tauchschüler'; + + @override + String get equipment_addSheet_brandHint => 'z. B. Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Marke'; + + @override + String get equipment_addSheet_closeTooltip => 'Schließen'; + + @override + String get equipment_addSheet_currencyLabel => 'Währung'; + + @override + String get equipment_addSheet_dateLabel => 'Datum'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Fehler beim Hinzufügen der Ausrüstung: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'z. B. MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Modell'; + + @override + String get equipment_addSheet_nameHint => 'z. B. Mein Hauptatemregler'; + + @override + String get equipment_addSheet_nameLabel => 'Name'; + + @override + String get equipment_addSheet_nameValidation => + 'Bitte geben Sie einen Namen ein'; + + @override + String get equipment_addSheet_notesHint => 'Zusätzliche Notizen...'; + + @override + String get equipment_addSheet_notesLabel => 'Notizen'; + + @override + String get equipment_addSheet_priceLabel => 'Preis'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Kaufinformationen'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Seriennummer'; + + @override + String get equipment_addSheet_serviceIntervalHint => 'z. B. 365 für jährlich'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Wartungsintervall (Tage)'; + + @override + String get equipment_addSheet_sizeHint => 'z. B. M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Größe'; + + @override + String get equipment_addSheet_submitButton => 'Ausrüstung hinzufügen'; + + @override + String get equipment_addSheet_successSnackbar => + 'Ausrüstung erfolgreich hinzugefügt'; + + @override + String get equipment_addSheet_title => 'Ausrüstung hinzufügen'; + + @override + String get equipment_addSheet_typeLabel => 'Typ'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Dieses Foto vom Tauchgang entfernen? Das Foto bleibt in Ihrer Galerie erhalten.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Foto trennen'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Foto getrennt'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count Geräte synchronisieren von einer neueren Version von Submersion. Aktualisieren Sie dieses Gerät, um deren neueste Änderungen zu erhalten.', + one: + '1 Gerät synchronisiert von einer neueren Version von Submersion. Aktualisieren Sie dieses Gerät, um dessen neueste Änderungen zu erhalten.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Aktivieren'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Luft)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Gesamte Grundzeit'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'Filtern nicht möglich: keine Seriennummer für diesen Computer.'; } diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index ed558da96e..d1c672dc5d 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -33433,4 +33433,405 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit is not available on this device'; + + @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'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'Professional Roles'; + + @override + String get buddies_roles_addRole => 'Add role'; + + @override + String get buddies_roles_role => 'Role'; + + @override + String get buddies_roles_agency => 'Agency'; + + @override + String get buddies_roles_credentialNumber => 'Credential number'; + + @override + String get buddies_roles_removeTooltip => 'Remove role'; + + @override + String get buddies_roles_emptyHint => + 'Add instructor or divemaster credentials to reuse them when logging certifications and courses.'; + + @override + String get buddies_detail_section_professionalRoles => 'Professional Roles'; + + @override + String get certifications_detail_label_level => 'Level'; + + @override + String get certifications_edit_hint_certificationName => + 'e.g., Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => + 'Certification Name *'; + + @override + String get certifications_edit_label_level => 'Level'; + + @override + String get certifications_edit_level_notSpecified => 'Not specified'; + + @override + String get certifications_edit_validation_nameRequired => + 'Please enter a certification name'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count certifications'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count certification'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Add your first certification'; + + @override + String get certifications_walletCard_error => 'Failed to load certifications'; + + @override + String get certifications_walletCard_semanticLabel => + 'Certification Wallet. Tap to view all certifications'; + + @override + String get certifications_walletCard_tapToAdd => 'Tap to add'; + + @override + String get certifications_walletCard_title => 'Certification Wallet'; + + @override + String get preDive_section_title => 'Pre-Dive Check'; + + @override + String get preDive_section_link => 'Link a checklist session'; + + @override + String get preDive_section_unlink => 'Unlink'; + + @override + String get preDive_section_run => 'Run pre-dive checklist'; + + @override + String get preDive_section_noUnlinked => 'No unlinked checklist sessions'; + + @override + String get diveDetailSection_preDiveChecklist_name => 'Pre-Dive Check'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Linked pre-dive checklist session'; + + @override + String get dashboard_photos_title => 'Recent photos'; + + @override + String get diveCenters_summary_topRated => 'Top Rated'; + + @override + String get diveLog_instruments_customize => 'Customize instruments'; + + @override + String get diveLog_instruments_customizeHint => + 'Toggle instruments on or off. Drag to reorder.'; + + @override + String get enum_buddyRole_buddy => 'Buddy'; + + @override + String get enum_buddyRole_diveGuide => 'Dive Guide'; + + @override + String get enum_buddyRole_diveMaster => 'Divemaster'; + + @override + String get enum_buddyRole_instructor => 'Instructor'; + + @override + String get enum_buddyRole_solo => 'Solo'; + + @override + String get enum_buddyRole_student => 'Student'; + + @override + String get equipment_addSheet_brandHint => 'e.g., Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Brand'; + + @override + String get equipment_addSheet_closeTooltip => 'Close'; + + @override + String get equipment_addSheet_currencyLabel => 'Currency'; + + @override + String get equipment_addSheet_dateLabel => 'Date'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Error adding equipment: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'e.g., MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Model'; + + @override + String get equipment_addSheet_nameHint => 'e.g., My Primary Regulator'; + + @override + String get equipment_addSheet_nameLabel => 'Name'; + + @override + String get equipment_addSheet_nameValidation => 'Please enter a name'; + + @override + String get equipment_addSheet_notesHint => 'Additional notes...'; + + @override + String get equipment_addSheet_notesLabel => 'Notes'; + + @override + String get equipment_addSheet_priceLabel => 'Price'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Purchase Information'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Serial Number'; + + @override + String get equipment_addSheet_serviceIntervalHint => 'e.g., 365 for yearly'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Service Interval (days)'; + + @override + String get equipment_addSheet_sizeHint => 'e.g., M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Size'; + + @override + String get equipment_addSheet_submitButton => 'Add Equipment'; + + @override + String get equipment_addSheet_successSnackbar => + 'Equipment added successfully'; + + @override + String get equipment_addSheet_title => 'Add Equipment'; + + @override + String get equipment_addSheet_typeLabel => 'Type'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Remove this photo from the dive? The photo will remain in your gallery.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Unlink Photo'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Photo unlinked'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count devices sync from a newer version of Submersion. Update this device to receive their latest changes.', + one: + '1 device syncs from a newer version of Submersion. Update this device to receive its latest changes.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Enable'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Air)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Total Bottom Time'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'Cannot filter: no serial number for this computer.'; } diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index a6dfb2c330..a2089ded83 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -34007,4 +34007,414 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit no está disponible en este dispositivo'; + + @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'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'Roles Profesionales'; + + @override + String get buddies_roles_addRole => 'Agregar rol'; + + @override + String get buddies_roles_role => 'Rol'; + + @override + String get buddies_roles_agency => 'Agencia'; + + @override + String get buddies_roles_credentialNumber => 'Número de credencial'; + + @override + String get buddies_roles_removeTooltip => 'Quitar rol'; + + @override + String get buddies_roles_emptyHint => + 'Agrega las credenciales de instructor o divemaster para reutilizarlas al registrar certificaciones y cursos.'; + + @override + String get buddies_detail_section_professionalRoles => 'Roles Profesionales'; + + @override + String get certifications_detail_label_level => 'Nivel'; + + @override + String get certifications_edit_hint_certificationName => + 'p. ej., Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => + 'Nombre de la certificacion *'; + + @override + String get certifications_edit_label_level => 'Nivel'; + + @override + String get certifications_edit_level_notSpecified => 'No especificado'; + + @override + String get certifications_edit_validation_nameRequired => + 'Por favor, introduce un nombre de certificacion'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count certificaciones'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count certificacion'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Agrega tu primera certificacion'; + + @override + String get certifications_walletCard_error => + 'Error al cargar certificaciones'; + + @override + String get certifications_walletCard_semanticLabel => + 'Cartera de certificaciones. Toca para ver todas las certificaciones'; + + @override + String get certifications_walletCard_tapToAdd => 'Toca para agregar'; + + @override + String get certifications_walletCard_title => 'Cartera de certificaciones'; + + @override + String get preDive_section_title => 'Comprobación previa a la inmersión'; + + @override + String get preDive_section_link => + 'Vincular una sesión de lista de verificación'; + + @override + String get preDive_section_unlink => 'Desvincular'; + + @override + String get preDive_section_run => 'Ejecutar lista previa a la inmersión'; + + @override + String get preDive_section_noUnlinked => + 'No hay sesiones de lista sin vincular'; + + @override + String get diveDetailSection_preDiveChecklist_name => + 'Comprobación previa a la inmersión'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Sesión de lista previa a la inmersión vinculada'; + + @override + String get dashboard_photos_title => 'Fotos recientes'; + + @override + String get diveCenters_summary_topRated => 'Mejor Calificados'; + + @override + String get diveLog_instruments_customize => 'Personalizar instrumentos'; + + @override + String get diveLog_instruments_customizeHint => + 'Activa o desactiva instrumentos. Arrastra para reordenar.'; + + @override + String get enum_buddyRole_buddy => 'Compañero'; + + @override + String get enum_buddyRole_diveGuide => 'Guía de buceo'; + + @override + String get enum_buddyRole_diveMaster => 'Divemaster'; + + @override + String get enum_buddyRole_instructor => 'Instructor'; + + @override + String get enum_buddyRole_solo => 'Solo'; + + @override + String get enum_buddyRole_student => 'Estudiante'; + + @override + String get equipment_addSheet_brandHint => 'p. ej., Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Marca'; + + @override + String get equipment_addSheet_closeTooltip => 'Cerrar'; + + @override + String get equipment_addSheet_currencyLabel => 'Moneda'; + + @override + String get equipment_addSheet_dateLabel => 'Fecha'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Error al agregar equipo: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'p. ej., MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Modelo'; + + @override + String get equipment_addSheet_nameHint => 'p. ej., Mi regulador principal'; + + @override + String get equipment_addSheet_nameLabel => 'Nombre'; + + @override + String get equipment_addSheet_nameValidation => 'Por favor ingresa un nombre'; + + @override + String get equipment_addSheet_notesHint => 'Notas adicionales...'; + + @override + String get equipment_addSheet_notesLabel => 'Notas'; + + @override + String get equipment_addSheet_priceLabel => 'Precio'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Informacion de compra'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Numero de serie'; + + @override + String get equipment_addSheet_serviceIntervalHint => 'p. ej., 365 para anual'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Intervalo de servicio (dias)'; + + @override + String get equipment_addSheet_sizeHint => 'p. ej., M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Talla'; + + @override + String get equipment_addSheet_submitButton => 'Agregar equipo'; + + @override + String get equipment_addSheet_successSnackbar => + 'Equipo agregado exitosamente'; + + @override + String get equipment_addSheet_title => 'Agregar equipo'; + + @override + String get equipment_addSheet_typeLabel => 'Tipo'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Eliminar esta foto de la inmersion? La foto permanecera en tu galeria.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Desvincular foto'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Foto desvinculada'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count dispositivos sincronizan desde una versión más reciente de Submersion. Actualiza este dispositivo para recibir sus últimos cambios.', + one: + '1 dispositivo sincroniza desde una versión más reciente de Submersion. Actualiza este dispositivo para recibir sus últimos cambios.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Activar'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Aire)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Tiempo de fondo total'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'No se puede filtrar: sin numero de serie para este ordenador.'; } diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 3bfcd39398..8843dcea80 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -34060,4 +34060,414 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit n\'est pas disponible sur cet appareil'; + + @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'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'Rôles professionnels'; + + @override + String get buddies_roles_addRole => 'Ajouter un rôle'; + + @override + String get buddies_roles_role => 'Rôle'; + + @override + String get buddies_roles_agency => 'Organisme'; + + @override + String get buddies_roles_credentialNumber => 'Numéro de qualification'; + + @override + String get buddies_roles_removeTooltip => 'Supprimer le rôle'; + + @override + String get buddies_roles_emptyHint => + 'Ajoutez les qualifications de moniteur ou de directeur de plongée pour les réutiliser lors de l\'enregistrement des certifications et des cours.'; + + @override + String get buddies_detail_section_professionalRoles => 'Rôles professionnels'; + + @override + String get certifications_detail_label_level => 'Niveau'; + + @override + String get certifications_edit_hint_certificationName => + 'ex. Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => + 'Nom de la certification *'; + + @override + String get certifications_edit_label_level => 'Niveau'; + + @override + String get certifications_edit_level_notSpecified => 'Non specifie'; + + @override + String get certifications_edit_validation_nameRequired => + 'Veuillez entrer un nom de certification'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count certifications'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count certification'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Ajoutez votre premiere certification'; + + @override + String get certifications_walletCard_error => + 'Echec du chargement des certifications'; + + @override + String get certifications_walletCard_semanticLabel => + 'Portefeuille de certifications. Appuyez pour voir toutes les certifications'; + + @override + String get certifications_walletCard_tapToAdd => 'Appuie pour ajouter'; + + @override + String get certifications_walletCard_title => + 'Portefeuille de certifications'; + + @override + String get preDive_section_title => 'Vérification pré-plongée'; + + @override + String get preDive_section_link => 'Lier une session de checklist'; + + @override + String get preDive_section_unlink => 'Dissocier'; + + @override + String get preDive_section_run => 'Lancer la checklist pré-plongée'; + + @override + String get preDive_section_noUnlinked => + 'Aucune session de checklist non liée'; + + @override + String get diveDetailSection_preDiveChecklist_name => + 'Vérification pré-plongée'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Session de checklist pré-plongée liée'; + + @override + String get dashboard_photos_title => 'Photos récentes'; + + @override + String get diveCenters_summary_topRated => 'Mieux notés'; + + @override + String get diveLog_instruments_customize => 'Personnaliser les instruments'; + + @override + String get diveLog_instruments_customizeHint => + 'Activez ou désactivez les instruments. Faites glisser pour réorganiser.'; + + @override + String get enum_buddyRole_buddy => 'Binome'; + + @override + String get enum_buddyRole_diveGuide => 'Guide de plongee'; + + @override + String get enum_buddyRole_diveMaster => 'Directeur de plongee'; + + @override + String get enum_buddyRole_instructor => 'Moniteur'; + + @override + String get enum_buddyRole_solo => 'Solo'; + + @override + String get enum_buddyRole_student => 'Eleve'; + + @override + String get equipment_addSheet_brandHint => 'ex. Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Marque'; + + @override + String get equipment_addSheet_closeTooltip => 'Fermer'; + + @override + String get equipment_addSheet_currencyLabel => 'Devise'; + + @override + String get equipment_addSheet_dateLabel => 'Date'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Erreur lors de l\'ajout de l\'equipement : $error'; + } + + @override + String get equipment_addSheet_modelHint => 'ex. MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Modele'; + + @override + String get equipment_addSheet_nameHint => 'ex. Mon detendeur principal'; + + @override + String get equipment_addSheet_nameLabel => 'Nom'; + + @override + String get equipment_addSheet_nameValidation => 'Veuillez entrer un nom'; + + @override + String get equipment_addSheet_notesHint => 'Notes supplementaires...'; + + @override + String get equipment_addSheet_notesLabel => 'Notes'; + + @override + String get equipment_addSheet_priceLabel => 'Prix'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Informations d\'achat'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Numero de serie'; + + @override + String get equipment_addSheet_serviceIntervalHint => 'ex. 365 pour annuel'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Intervalle de revision (jours)'; + + @override + String get equipment_addSheet_sizeHint => 'ex. M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Taille'; + + @override + String get equipment_addSheet_submitButton => 'Ajouter l\'equipement'; + + @override + String get equipment_addSheet_successSnackbar => + 'Equipement ajoute avec succes'; + + @override + String get equipment_addSheet_title => 'Ajouter un equipement'; + + @override + String get equipment_addSheet_typeLabel => 'Type'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Retirer cette photo de la plongee ? La photo restera dans ta galerie.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Dissocier la photo'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Photo dissociee'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count appareils se synchronisent depuis une version plus récente de Submersion. Mettez à jour cet appareil pour recevoir leurs derniers changements.', + one: + '1 appareil se synchronise depuis une version plus récente de Submersion. Mettez à jour cet appareil pour recevoir ses derniers changements.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Activer'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Air)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Temps au fond total'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'Filtrage impossible : aucun numero de serie pour cet ordinateur.'; } diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 116fda3e6a..f9bd2dec79 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -33280,4 +33280,401 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit אינו זמין במכשיר הזה'; + + @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 => 'ציוד והסמכות'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'תפקידים מקצועיים'; + + @override + String get buddies_roles_addRole => 'הוסף תפקיד'; + + @override + String get buddies_roles_role => 'תפקיד'; + + @override + String get buddies_roles_agency => 'גוף הסמכה'; + + @override + String get buddies_roles_credentialNumber => 'מספר הסמכה'; + + @override + String get buddies_roles_removeTooltip => 'הסר תפקיד'; + + @override + String get buddies_roles_emptyHint => + 'הוסף הסמכות מדריך או דייבמאסטר לשימוש חוזר בעת רישום הסמכות וקורסים.'; + + @override + String get buddies_detail_section_professionalRoles => 'תפקידים מקצועיים'; + + @override + String get certifications_detail_label_level => 'רמה'; + + @override + String get certifications_edit_hint_certificationName => + 'לדוגמה, Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => 'שם הסמכה *'; + + @override + String get certifications_edit_label_level => 'רמה'; + + @override + String get certifications_edit_level_notSpecified => 'לא צוין'; + + @override + String get certifications_edit_validation_nameRequired => 'נא להזין שם הסמכה'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count הסמכות'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return 'הסמכה $count'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'הוסף את ההסמכה הראשונה שלך'; + + @override + String get certifications_walletCard_error => 'טעינת ההסמכות נכשלה'; + + @override + String get certifications_walletCard_semanticLabel => + 'ארנק הסמכות. הקש כדי לצפות בכל ההסמכות'; + + @override + String get certifications_walletCard_tapToAdd => 'הקש להוספה'; + + @override + String get certifications_walletCard_title => 'ארנק הסמכות'; + + @override + String get preDive_section_title => 'בדיקה לפני צלילה'; + + @override + String get preDive_section_link => 'קשר הרצת רשימת בדיקה'; + + @override + String get preDive_section_unlink => 'בטל קישור'; + + @override + String get preDive_section_run => 'הרץ רשימת בדיקה לפני צלילה'; + + @override + String get preDive_section_noUnlinked => 'אין הרצות רשימת בדיקה לא מקושרות'; + + @override + String get diveDetailSection_preDiveChecklist_name => 'בדיקה לפני צלילה'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'הרצת רשימת בדיקה לפני צלילה מקושרת'; + + @override + String get dashboard_photos_title => 'תמונות אחרונות'; + + @override + String get diveCenters_summary_topRated => 'מדורג ביותר'; + + @override + String get diveLog_instruments_customize => 'התאמה אישית של מכשירים'; + + @override + String get diveLog_instruments_customizeHint => + 'הפעל או כבה מכשירים. גרור כדי לסדר מחדש.'; + + @override + String get enum_buddyRole_buddy => 'שותף'; + + @override + String get enum_buddyRole_diveGuide => 'מדריך צלילה'; + + @override + String get enum_buddyRole_diveMaster => 'דייבמאסטר'; + + @override + String get enum_buddyRole_instructor => 'מדריך'; + + @override + String get enum_buddyRole_solo => 'יחיד'; + + @override + String get enum_buddyRole_student => 'תלמיד'; + + @override + String get equipment_addSheet_brandHint => 'לדוגמה, Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'מותג'; + + @override + String get equipment_addSheet_closeTooltip => 'סגור'; + + @override + String get equipment_addSheet_currencyLabel => 'מטבע'; + + @override + String get equipment_addSheet_dateLabel => 'תאריך'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'שגיאה בהוספת ציוד: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'לדוגמה, MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'דגם'; + + @override + String get equipment_addSheet_nameHint => 'לדוגמה, הרגולטור הראשי שלי'; + + @override + String get equipment_addSheet_nameLabel => 'שם'; + + @override + String get equipment_addSheet_nameValidation => 'נא להזין שם'; + + @override + String get equipment_addSheet_notesHint => 'הערות נוספות...'; + + @override + String get equipment_addSheet_notesLabel => 'הערות'; + + @override + String get equipment_addSheet_priceLabel => 'מחיר'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'פרטי רכישה'; + + @override + String get equipment_addSheet_serialNumberLabel => 'מספר סידורי'; + + @override + String get equipment_addSheet_serviceIntervalHint => 'לדוגמה, 365 לשנתי'; + + @override + String get equipment_addSheet_serviceIntervalLabel => 'מרווח טיפול (ימים)'; + + @override + String get equipment_addSheet_sizeHint => 'לדוגמה, M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'מידה'; + + @override + String get equipment_addSheet_submitButton => 'הוסף ציוד'; + + @override + String get equipment_addSheet_successSnackbar => 'הציוד נוסף בהצלחה'; + + @override + String get equipment_addSheet_title => 'הוסף ציוד'; + + @override + String get equipment_addSheet_typeLabel => 'סוג'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'להסיר תמונה זו מהצלילה? התמונה תישאר בגלריה שלך.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'ביטול קישור תמונה'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'קישור התמונה בוטל'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count מכשירים מסתנכרנים מגרסה חדשה יותר של Submersion. עדכן מכשיר זה כדי לקבל את השינויים האחרונים שלהם.', + one: + 'מכשיר אחד מסתנכרן מגרסה חדשה יותר של Submersion. עדכן מכשיר זה כדי לקבל את השינויים האחרונים שלו.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'אפשר'; + + @override + String get surfaceInterval_secondDive_gasAir => '(אוויר)'; + + @override + String get trips_detail_stat_totalBottomTime => 'סה\"כ זמן תחתית'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'לא ניתן לסנן: אין מספר סידורי למחשב זה.'; } diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 966351b8e3..94ade33fb9 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -33835,4 +33835,413 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'A HealthKit nem érhető el ezen az eszközön'; + + @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'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'Szakmai szerepek'; + + @override + String get buddies_roles_addRole => 'Szerep hozzáadása'; + + @override + String get buddies_roles_role => 'Szerep'; + + @override + String get buddies_roles_agency => 'Szervezet'; + + @override + String get buddies_roles_credentialNumber => 'Igazolványszám'; + + @override + String get buddies_roles_removeTooltip => 'Szerep eltávolítása'; + + @override + String get buddies_roles_emptyHint => + 'Adja hozzá az oktatói vagy divemaster képesítéseket, hogy újra felhasználhassa őket képesítések és tanfolyamok rögzítésekor.'; + + @override + String get buddies_detail_section_professionalRoles => 'Szakmai szerepek'; + + @override + String get certifications_detail_label_level => 'Szint'; + + @override + String get certifications_edit_hint_certificationName => + 'pl. Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => 'Kepesites neve *'; + + @override + String get certifications_edit_label_level => 'Szint'; + + @override + String get certifications_edit_level_notSpecified => 'Nincs megadva'; + + @override + String get certifications_edit_validation_nameRequired => + 'Kerem, adja meg a tanusitvany nevet'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count tanusitvany'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count tanusitvany'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Adja hozza az elso tanusitvanyt'; + + @override + String get certifications_walletCard_error => + 'Nem sikerult a tanusitványok betoltese'; + + @override + String get certifications_walletCard_semanticLabel => + 'Tanusitvany tarca. Koppintson az osszes tanusitvany megtekintésehez'; + + @override + String get certifications_walletCard_tapToAdd => 'Koppintson a hozzaadashoz'; + + @override + String get certifications_walletCard_title => 'Tanusitvany tarca'; + + @override + String get preDive_section_title => 'Merülés előtti ellenőrzés'; + + @override + String get preDive_section_link => 'Ellenőrzőlista-munkamenet csatolása'; + + @override + String get preDive_section_unlink => 'Csatolás megszüntetése'; + + @override + String get preDive_section_run => 'Merülés előtti ellenőrzőlista futtatása'; + + @override + String get preDive_section_noUnlinked => + 'Nincsenek nem csatolt ellenőrzőlista-munkamenetek'; + + @override + String get diveDetailSection_preDiveChecklist_name => + 'Merülés előtti ellenőrzés'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Kapcsolt merülés előtti ellenőrzőlista-munkamenet'; + + @override + String get dashboard_photos_title => 'Legutóbbi fotók'; + + @override + String get diveCenters_summary_topRated => 'Legjobbra értékelt'; + + @override + String get diveLog_instruments_customize => 'Műszerek testreszabása'; + + @override + String get diveLog_instruments_customizeHint => + 'Kapcsolja be vagy ki a műszereket. Húzza az átrendezéshez.'; + + @override + String get enum_buddyRole_buddy => 'Buddy'; + + @override + String get enum_buddyRole_diveGuide => 'Merulesvezeto'; + + @override + String get enum_buddyRole_diveMaster => 'Divemaster'; + + @override + String get enum_buddyRole_instructor => 'Oktato'; + + @override + String get enum_buddyRole_solo => 'Solo'; + + @override + String get enum_buddyRole_student => 'Tanulo'; + + @override + String get equipment_addSheet_brandHint => 'pl. Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Marka'; + + @override + String get equipment_addSheet_closeTooltip => 'Bezaras'; + + @override + String get equipment_addSheet_currencyLabel => 'Penznem'; + + @override + String get equipment_addSheet_dateLabel => 'Datum'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Hiba a felszereles hozzaadasakor: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'pl. MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Modell'; + + @override + String get equipment_addSheet_nameHint => 'pl. Elsooleges automata'; + + @override + String get equipment_addSheet_nameLabel => 'Nev'; + + @override + String get equipment_addSheet_nameValidation => 'Kerem adjon meg egy nevet'; + + @override + String get equipment_addSheet_notesHint => 'Tovabbl megjegyzesek...'; + + @override + String get equipment_addSheet_notesLabel => 'Megjegyzesek'; + + @override + String get equipment_addSheet_priceLabel => 'Ar'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Vasarlasi informaciok'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Sorozatszam'; + + @override + String get equipment_addSheet_serviceIntervalHint => + 'pl. 365 az eves szervizhez'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Szerviz intervallum (nap)'; + + @override + String get equipment_addSheet_sizeHint => 'pl. M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Meret'; + + @override + String get equipment_addSheet_submitButton => 'Felszereles hozzaadasa'; + + @override + String get equipment_addSheet_successSnackbar => + 'Felszereles sikeresen hozzaadva'; + + @override + String get equipment_addSheet_title => 'Felszereles hozzaadasa'; + + @override + String get equipment_addSheet_typeLabel => 'Tipus'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Eltavolitja ezt a fotot a merülesrol? A foto megmarad a galeriadjaban.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Foto levalasztasa'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Foto levalasztva'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count eszköz a Submersion újabb verziójából szinkronizál. Frissítsd ezt az eszközt, hogy megkapd a legújabb változtatásaikat.', + one: + '1 eszköz a Submersion újabb verziójából szinkronizál. Frissítsd ezt az eszközt, hogy megkapd a legújabb változtatásait.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Engedelyezes'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Levegő)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Osszes fenekido'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'Nem lehet szurni: nincs sorozatszam ehhez a szamitogephez.'; } diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index bec3b8f330..e08af04d0c 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -33960,4 +33960,413 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit non è disponibile su questo dispositivo'; + + @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'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'Ruoli Professionali'; + + @override + String get buddies_roles_addRole => 'Aggiungi ruolo'; + + @override + String get buddies_roles_role => 'Ruolo'; + + @override + String get buddies_roles_agency => 'Agenzia'; + + @override + String get buddies_roles_credentialNumber => 'Numero di credenziale'; + + @override + String get buddies_roles_removeTooltip => 'Rimuovi ruolo'; + + @override + String get buddies_roles_emptyHint => + 'Aggiungi le credenziali di istruttore o divemaster per riutilizzarle durante la registrazione di certificazioni e corsi.'; + + @override + String get buddies_detail_section_professionalRoles => 'Ruoli Professionali'; + + @override + String get certifications_detail_label_level => 'Livello'; + + @override + String get certifications_edit_hint_certificationName => + 'es. Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => + 'Nome certificazione *'; + + @override + String get certifications_edit_label_level => 'Livello'; + + @override + String get certifications_edit_level_notSpecified => 'Non specificato'; + + @override + String get certifications_edit_validation_nameRequired => + 'Inserisci un nome per la certificazione'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count certificazioni'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count certificazione'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Aggiungi la tua prima certificazione'; + + @override + String get certifications_walletCard_error => + 'Impossibile caricare le certificazioni'; + + @override + String get certifications_walletCard_semanticLabel => + 'Portafoglio certificazioni. Tocca per visualizzare tutte le certificazioni'; + + @override + String get certifications_walletCard_tapToAdd => 'Tocca per aggiungere'; + + @override + String get certifications_walletCard_title => 'Portafoglio certificazioni'; + + @override + String get preDive_section_title => 'Controllo pre-immersione'; + + @override + String get preDive_section_link => 'Collega una sessione di checklist'; + + @override + String get preDive_section_unlink => 'Scollega'; + + @override + String get preDive_section_run => 'Esegui checklist pre-immersione'; + + @override + String get preDive_section_noUnlinked => + 'Nessuna sessione di checklist non collegata'; + + @override + String get diveDetailSection_preDiveChecklist_name => + 'Controllo pre-immersione'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Sessione di checklist pre-immersione collegata'; + + @override + String get dashboard_photos_title => 'Foto recenti'; + + @override + String get diveCenters_summary_topRated => 'Più Votati'; + + @override + String get diveLog_instruments_customize => 'Personalizza strumenti'; + + @override + String get diveLog_instruments_customizeHint => + 'Attiva o disattiva gli strumenti. Trascina per riordinare.'; + + @override + String get enum_buddyRole_buddy => 'Compagno'; + + @override + String get enum_buddyRole_diveGuide => 'Guida subacquea'; + + @override + String get enum_buddyRole_diveMaster => 'Divemaster'; + + @override + String get enum_buddyRole_instructor => 'Istruttore'; + + @override + String get enum_buddyRole_solo => 'Solitario'; + + @override + String get enum_buddyRole_student => 'Allievo'; + + @override + String get equipment_addSheet_brandHint => 'es. Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Marca'; + + @override + String get equipment_addSheet_closeTooltip => 'Chiudi'; + + @override + String get equipment_addSheet_currencyLabel => 'Valuta'; + + @override + String get equipment_addSheet_dateLabel => 'Data'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Errore nell\'aggiunta dell\'attrezzatura: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'es. MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Modello'; + + @override + String get equipment_addSheet_nameHint => 'es. Il mio erogatore principale'; + + @override + String get equipment_addSheet_nameLabel => 'Nome'; + + @override + String get equipment_addSheet_nameValidation => 'Inserisci un nome'; + + @override + String get equipment_addSheet_notesHint => 'Note aggiuntive...'; + + @override + String get equipment_addSheet_notesLabel => 'Note'; + + @override + String get equipment_addSheet_priceLabel => 'Prezzo'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Informazioni acquisto'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Numero di serie'; + + @override + String get equipment_addSheet_serviceIntervalHint => 'es. 365 per annuale'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Intervallo manutenzione (giorni)'; + + @override + String get equipment_addSheet_sizeHint => 'es. M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Taglia'; + + @override + String get equipment_addSheet_submitButton => 'Aggiungi attrezzatura'; + + @override + String get equipment_addSheet_successSnackbar => + 'Attrezzatura aggiunta con successo'; + + @override + String get equipment_addSheet_title => 'Aggiungi attrezzatura'; + + @override + String get equipment_addSheet_typeLabel => 'Tipo'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Rimuovere questa foto dall\'immersione? La foto rimarrà nella tua galleria.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Scollega foto'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Foto scollegata'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count dispositivi si sincronizzano da una versione più recente di Submersion. Aggiorna questo dispositivo per ricevere le loro ultime modifiche.', + one: + '1 dispositivo si sincronizza da una versione più recente di Submersion. Aggiorna questo dispositivo per ricevere le sue ultime modifiche.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Abilita'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Aria)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Tempo di fondo totale'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'Impossibile filtrare: nessun numero di serie per questo computer.'; } diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 13bb71ab94..378715bdff 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -33728,4 +33728,410 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'HealthKit is niet beschikbaar op dit apparaat'; + + @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'; + + @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'; + + @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'; + + @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 get buddies_section_professionalRoles => 'Professionele rollen'; + + @override + String get buddies_roles_addRole => 'Rol toevoegen'; + + @override + String get buddies_roles_role => 'Rol'; + + @override + String get buddies_roles_agency => 'Organisatie'; + + @override + String get buddies_roles_credentialNumber => 'Registratienummer'; + + @override + String get buddies_roles_removeTooltip => 'Rol verwijderen'; + + @override + String get buddies_roles_emptyHint => + 'Voeg instructeur- of divemasterkwalificaties toe, zodat je ze kunt hergebruiken bij het registreren van certificeringen en cursussen.'; + + @override + String get buddies_detail_section_professionalRoles => 'Professionele rollen'; + + @override + String get certifications_detail_label_level => 'Niveau'; + + @override + String get certifications_edit_hint_certificationName => + 'bijv. Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => + 'Certificeringsnaam *'; + + @override + String get certifications_edit_label_level => 'Niveau'; + + @override + String get certifications_edit_level_notSpecified => 'Niet opgegeven'; + + @override + String get certifications_edit_validation_nameRequired => + 'Voer een certificeringsnaam in'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count certificeringen'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count certificering'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Voeg je eerste certificering toe'; + + @override + String get certifications_walletCard_error => + 'Kan certificeringen niet laden'; + + @override + String get certifications_walletCard_semanticLabel => + 'Certificeringsportemonnee. Tik om alle certificeringen te bekijken'; + + @override + String get certifications_walletCard_tapToAdd => 'Tik om toe te voegen'; + + @override + String get certifications_walletCard_title => 'Certificeringsportemonnee'; + + @override + String get preDive_section_title => 'Pre-dive check'; + + @override + String get preDive_section_link => 'Checklistsessie koppelen'; + + @override + String get preDive_section_unlink => 'Ontkoppelen'; + + @override + String get preDive_section_run => 'Pre-dive checklist uitvoeren'; + + @override + String get preDive_section_noUnlinked => 'Geen ontkoppelde checklistsessies'; + + @override + String get diveDetailSection_preDiveChecklist_name => 'Pre-dive check'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Gekoppelde pre-dive checklistsessie'; + + @override + String get dashboard_photos_title => 'Recente foto\'s'; + + @override + String get diveCenters_summary_topRated => 'Best beoordeeld'; + + @override + String get diveLog_instruments_customize => 'Instrumenten aanpassen'; + + @override + String get diveLog_instruments_customizeHint => + 'Schakel instrumenten in of uit. Sleep om te herschikken.'; + + @override + String get enum_buddyRole_buddy => 'Buddy'; + + @override + String get enum_buddyRole_diveGuide => 'Duikgids'; + + @override + String get enum_buddyRole_diveMaster => 'Divemaster'; + + @override + String get enum_buddyRole_instructor => 'Instructeur'; + + @override + String get enum_buddyRole_solo => 'Solo'; + + @override + String get enum_buddyRole_student => 'Leerling'; + + @override + String get equipment_addSheet_brandHint => 'bijv. Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Merk'; + + @override + String get equipment_addSheet_closeTooltip => 'Sluiten'; + + @override + String get equipment_addSheet_currencyLabel => 'Valuta'; + + @override + String get equipment_addSheet_dateLabel => 'Datum'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Fout bij toevoegen van uitrusting: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'bijv. MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Model'; + + @override + String get equipment_addSheet_nameHint => 'bijv. Mijn primaire ademautomaat'; + + @override + String get equipment_addSheet_nameLabel => 'Naam'; + + @override + String get equipment_addSheet_nameValidation => 'Voer een naam in'; + + @override + String get equipment_addSheet_notesHint => 'Extra notities...'; + + @override + String get equipment_addSheet_notesLabel => 'Notities'; + + @override + String get equipment_addSheet_priceLabel => 'Prijs'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Aankoopinformatie'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Serienummer'; + + @override + String get equipment_addSheet_serviceIntervalHint => + 'bijv. 365 voor jaarlijks'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Serviceinterval (dagen)'; + + @override + String get equipment_addSheet_sizeHint => 'bijv. M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Maat'; + + @override + String get equipment_addSheet_submitButton => 'Uitrusting toevoegen'; + + @override + String get equipment_addSheet_successSnackbar => + 'Uitrusting succesvol toegevoegd'; + + @override + String get equipment_addSheet_title => 'Uitrusting toevoegen'; + + @override + String get equipment_addSheet_typeLabel => 'Type'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Deze foto van de duik verwijderen? De foto blijft in je galerij staan.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Foto ontkoppelen'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Foto ontkoppeld'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count apparaten synchroniseren vanaf een nieuwere versie van Submersion. Werk dit apparaat bij om hun nieuwste wijzigingen te ontvangen.', + one: + '1 apparaat synchroniseert vanaf een nieuwere versie van Submersion. Werk dit apparaat bij om de nieuwste wijzigingen ervan te ontvangen.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Inschakelen'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Lucht)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Totale bodemtijd'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'Filteren niet mogelijk: geen serienummer voor deze computer.'; } diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 6ab3451c4e..bba97e6ae9 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -33974,4 +33974,417 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => 'O HealthKit não está disponível neste dispositivo'; + + @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'; + + @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'; + + @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 já 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'; + + @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 get buddies_section_professionalRoles => 'Funções Profissionais'; + + @override + String get buddies_roles_addRole => 'Adicionar função'; + + @override + String get buddies_roles_role => 'Função'; + + @override + String get buddies_roles_agency => 'Agência'; + + @override + String get buddies_roles_credentialNumber => 'Número de credencial'; + + @override + String get buddies_roles_removeTooltip => 'Remover função'; + + @override + String get buddies_roles_emptyHint => + 'Adicione as credenciais de instrutor ou divemaster para reutilizá-las ao registrar certificações e cursos.'; + + @override + String get buddies_detail_section_professionalRoles => + 'Funções Profissionais'; + + @override + String get certifications_detail_label_level => 'Nivel'; + + @override + String get certifications_edit_hint_certificationName => + 'ex., Open Water Diver'; + + @override + String get certifications_edit_label_certificationName => + 'Nome da Certificacao *'; + + @override + String get certifications_edit_label_level => 'Nivel'; + + @override + String get certifications_edit_level_notSpecified => 'Nao especificado'; + + @override + String get certifications_edit_validation_nameRequired => + 'Por favor, insira um nome de certificacao'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count certificacoes'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count certificacao'; + } + + @override + String get certifications_walletCard_emptyFooter => + 'Adicione sua primeira certificacao'; + + @override + String get certifications_walletCard_error => + 'Falha ao carregar certificacoes'; + + @override + String get certifications_walletCard_semanticLabel => + 'Carteira de Certificacoes. Toque para ver todas as certificacoes'; + + @override + String get certifications_walletCard_tapToAdd => 'Toque para adicionar'; + + @override + String get certifications_walletCard_title => 'Carteira de Certificacoes'; + + @override + String get preDive_section_title => 'Verificação Pré-Mergulho'; + + @override + String get preDive_section_link => + 'Vincular uma sessão de lista de verificação'; + + @override + String get preDive_section_unlink => 'Desvincular'; + + @override + String get preDive_section_run => + 'Executar lista de verificação pré-mergulho'; + + @override + String get preDive_section_noUnlinked => + 'Nenhuma sessão de lista de verificação desvinculada'; + + @override + String get diveDetailSection_preDiveChecklist_name => + 'Verificação Pré-Mergulho'; + + @override + String get diveDetailSection_preDiveChecklist_description => + 'Sessão de lista de verificação pré-mergulho vinculada'; + + @override + String get dashboard_photos_title => 'Fotos recentes'; + + @override + String get diveCenters_summary_topRated => 'Mais Bem Avaliados'; + + @override + String get diveLog_instruments_customize => 'Personalizar instrumentos'; + + @override + String get diveLog_instruments_customizeHint => + 'Ative ou desative instrumentos. Arraste para reordenar.'; + + @override + String get enum_buddyRole_buddy => 'Dupla'; + + @override + String get enum_buddyRole_diveGuide => 'Guia de Mergulho'; + + @override + String get enum_buddyRole_diveMaster => 'Divemaster'; + + @override + String get enum_buddyRole_instructor => 'Instrutor'; + + @override + String get enum_buddyRole_solo => 'Solo'; + + @override + String get enum_buddyRole_student => 'Aluno'; + + @override + String get equipment_addSheet_brandHint => 'ex., Scubapro'; + + @override + String get equipment_addSheet_brandLabel => 'Marca'; + + @override + String get equipment_addSheet_closeTooltip => 'Fechar'; + + @override + String get equipment_addSheet_currencyLabel => 'Moeda'; + + @override + String get equipment_addSheet_dateLabel => 'Data'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return 'Erro ao adicionar equipamento: $error'; + } + + @override + String get equipment_addSheet_modelHint => 'ex., MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => 'Modelo'; + + @override + String get equipment_addSheet_nameHint => 'ex., Meu Regulador Principal'; + + @override + String get equipment_addSheet_nameLabel => 'Nome'; + + @override + String get equipment_addSheet_nameValidation => 'Por favor, insira um nome'; + + @override + String get equipment_addSheet_notesHint => 'Observacoes adicionais...'; + + @override + String get equipment_addSheet_notesLabel => 'Observacoes'; + + @override + String get equipment_addSheet_priceLabel => 'Preco'; + + @override + String get equipment_addSheet_purchaseInfoTitle => 'Informacoes de Compra'; + + @override + String get equipment_addSheet_serialNumberLabel => 'Numero de Serie'; + + @override + String get equipment_addSheet_serviceIntervalHint => + 'ex., 365 para anualmente'; + + @override + String get equipment_addSheet_serviceIntervalLabel => + 'Intervalo de Manutencao (dias)'; + + @override + String get equipment_addSheet_sizeHint => 'ex., M, G, 42'; + + @override + String get equipment_addSheet_sizeLabel => 'Tamanho'; + + @override + String get equipment_addSheet_submitButton => 'Adicionar Equipamento'; + + @override + String get equipment_addSheet_successSnackbar => + 'Equipamento adicionado com sucesso'; + + @override + String get equipment_addSheet_title => 'Adicionar Equipamento'; + + @override + String get equipment_addSheet_typeLabel => 'Tipo'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + 'Remover esta foto do mergulho? A foto permanecera na sua galeria.'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => 'Desvincular Foto'; + + @override + String get media_diveMediaSection_unlinkSuccess => 'Foto desvinculada'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count dispositivos sincronizam a partir de uma versão mais recente do Submersion. Atualize este dispositivo para receber as alterações mais recentes deles.', + one: + '1 dispositivo sincroniza a partir de uma versão mais recente do Submersion. Atualize este dispositivo para receber as alterações mais recentes dele.', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => 'Ativar'; + + @override + String get surfaceInterval_secondDive_gasAir => '(Ar)'; + + @override + String get trips_detail_stat_totalBottomTime => 'Tempo de Fundo Total'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => + 'Nao e possivel filtrar: sem numero de serie para este computador.'; } diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index c9e8028fb0..aca727af6f 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -31999,4 +31999,390 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_dataSources_appleHealth_permissionUnsupported => '此设备不支持 HealthKit'; + + @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 => '装备与证书'; + + @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 同步'; + + @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'; + + @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 get buddies_section_professionalRoles => '专业角色'; + + @override + String get buddies_roles_addRole => '添加角色'; + + @override + String get buddies_roles_role => '角色'; + + @override + String get buddies_roles_agency => '机构'; + + @override + String get buddies_roles_credentialNumber => '资质编号'; + + @override + String get buddies_roles_removeTooltip => '移除角色'; + + @override + String get buddies_roles_emptyHint => '添加教练或潜水长资质,以便在记录认证和课程时重复使用。'; + + @override + String get buddies_detail_section_professionalRoles => '专业角色'; + + @override + String get certifications_detail_label_level => '等级'; + + @override + String get certifications_edit_hint_certificationName => '例如,开放水域潜水员'; + + @override + String get certifications_edit_label_certificationName => '证书名称 *'; + + @override + String get certifications_edit_label_level => '等级'; + + @override + String get certifications_edit_level_notSpecified => '未指定'; + + @override + String get certifications_edit_validation_nameRequired => '请输入证书名称'; + + @override + String certifications_walletCard_countPlural(Object count) { + return '$count 个证书'; + } + + @override + String certifications_walletCard_countSingular(Object count) { + return '$count 个证书'; + } + + @override + String get certifications_walletCard_emptyFooter => '添加您的第一个证书'; + + @override + String get certifications_walletCard_error => '加载证书失败'; + + @override + String get certifications_walletCard_semanticLabel => '证书卡包。点击查看所有证书'; + + @override + String get certifications_walletCard_tapToAdd => '点击添加'; + + @override + String get certifications_walletCard_title => '证书卡包'; + + @override + String get preDive_section_title => '潜前检查'; + + @override + String get preDive_section_link => '关联检查清单记录'; + + @override + String get preDive_section_unlink => '取消关联'; + + @override + String get preDive_section_run => '执行潜前检查清单'; + + @override + String get preDive_section_noUnlinked => '没有未关联的检查清单记录'; + + @override + String get diveDetailSection_preDiveChecklist_name => '潜前检查'; + + @override + String get diveDetailSection_preDiveChecklist_description => '已关联的潜前检查清单记录'; + + @override + String get dashboard_photos_title => '最近照片'; + + @override + String get diveCenters_summary_topRated => '评分最高'; + + @override + String get diveLog_instruments_customize => '自定义仪表'; + + @override + String get diveLog_instruments_customizeHint => '开启或关闭仪表。拖动以重新排序。'; + + @override + String get enum_buddyRole_buddy => '潜伴'; + + @override + String get enum_buddyRole_diveGuide => '潜水指南'; + + @override + String get enum_buddyRole_diveMaster => '潜水长'; + + @override + String get enum_buddyRole_instructor => '教练'; + + @override + String get enum_buddyRole_solo => '单人'; + + @override + String get enum_buddyRole_student => '学生'; + + @override + String get equipment_addSheet_brandHint => '例如 Scubapro'; + + @override + String get equipment_addSheet_brandLabel => '品牌'; + + @override + String get equipment_addSheet_closeTooltip => '关闭'; + + @override + String get equipment_addSheet_currencyLabel => '货币'; + + @override + String get equipment_addSheet_dateLabel => '日期'; + + @override + String equipment_addSheet_errorSnackbar(Object error) { + return '添加装备出错:$error'; + } + + @override + String get equipment_addSheet_modelHint => '例如 MK25 EVO'; + + @override + String get equipment_addSheet_modelLabel => '型号'; + + @override + String get equipment_addSheet_nameHint => '例如:我的主调节器'; + + @override + String get equipment_addSheet_nameLabel => '名称'; + + @override + String get equipment_addSheet_nameValidation => '请输入名称'; + + @override + String get equipment_addSheet_notesHint => '其他备注...'; + + @override + String get equipment_addSheet_notesLabel => '备注'; + + @override + String get equipment_addSheet_priceLabel => '价格'; + + @override + String get equipment_addSheet_purchaseInfoTitle => '购买信息'; + + @override + String get equipment_addSheet_serialNumberLabel => '序列编号'; + + @override + String get equipment_addSheet_serviceIntervalHint => '例如 365 表示每年'; + + @override + String get equipment_addSheet_serviceIntervalLabel => '维护间隔(天)'; + + @override + String get equipment_addSheet_sizeHint => 'e.g., M, L, 42'; + + @override + String get equipment_addSheet_sizeLabel => '尺寸'; + + @override + String get equipment_addSheet_submitButton => '添加装备'; + + @override + String get equipment_addSheet_successSnackbar => '装备添加成功'; + + @override + String get equipment_addSheet_title => '添加装备'; + + @override + String get equipment_addSheet_typeLabel => '类型'; + + @override + String get media_diveMediaSection_unlinkDialogContent => + '从此次潜水中移除此照片吗?照片将保留在您的相册中。'; + + @override + String get media_diveMediaSection_unlinkDialogTitle => '取消关联照片'; + + @override + String get media_diveMediaSection_unlinkSuccess => '照片已取消关联'; + + @override + String settings_cloudSync_peerRequiresUpdate_banner(num count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 台设备正在从更新版本的 Submersion 同步。请更新此设备以接收它们的最新更改。', + one: '1 台设备正在从更新版本的 Submersion 同步。请更新此设备以接收其最新更改。', + ); + return '$_temp0'; + } + + @override + String get settings_notifications_disabled_enableButton => '启用'; + + @override + String get surfaceInterval_secondDive_gasAir => '(空气)'; + + @override + String get trips_detail_stat_totalBottomTime => '总计底部时间'; + + @override + String get diveComputer_detail_cannotFilterNoSerial => '无法筛选:此电脑没有序列号。'; } diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 80225a3940..3e4aca4e5a 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "Waterdiepte - dieptemetingen die tijdens duiken zijn vastgelegd", "settings_dataSources_appleHealth_dataTypeWaterTemp": "Watertemperatuur - temperatuurmetingen die tijdens duiken zijn vastgelegd", "settings_dataSources_appleHealth_permissionManagedInHealth": "HealthKit-toegang beheer je in de app Gezondheid", - "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit is niet beschikbaar op dit apparaat" + "settings_dataSources_appleHealth_permissionUnsupported": "HealthKit is niet beschikbaar op dit apparaat", + "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", + "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", + "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", + "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", + "buddies_section_professionalRoles": "Professionele rollen", + "buddies_roles_addRole": "Rol toevoegen", + "buddies_roles_role": "Rol", + "buddies_roles_agency": "Organisatie", + "buddies_roles_credentialNumber": "Registratienummer", + "buddies_roles_removeTooltip": "Rol verwijderen", + "buddies_roles_emptyHint": "Voeg instructeur- of divemasterkwalificaties toe, zodat je ze kunt hergebruiken bij het registreren van certificeringen en cursussen.", + "buddies_detail_section_professionalRoles": "Professionele rollen", + "certifications_detail_label_level": "Niveau", + "certifications_edit_hint_certificationName": "bijv. Open Water Diver", + "certifications_edit_label_certificationName": "Certificeringsnaam *", + "certifications_edit_label_level": "Niveau", + "certifications_edit_level_notSpecified": "Niet opgegeven", + "certifications_edit_validation_nameRequired": "Voer een certificeringsnaam in", + "certifications_walletCard_countPlural": "{count} certificeringen", + "certifications_walletCard_countSingular": "{count} certificering", + "certifications_walletCard_emptyFooter": "Voeg je eerste certificering toe", + "certifications_walletCard_error": "Kan certificeringen niet laden", + "certifications_walletCard_semanticLabel": "Certificeringsportemonnee. Tik om alle certificeringen te bekijken", + "certifications_walletCard_tapToAdd": "Tik om toe te voegen", + "certifications_walletCard_title": "Certificeringsportemonnee", + "preDive_section_title": "Pre-dive check", + "preDive_section_link": "Checklistsessie koppelen", + "preDive_section_unlink": "Ontkoppelen", + "preDive_section_run": "Pre-dive checklist uitvoeren", + "preDive_section_noUnlinked": "Geen ontkoppelde checklistsessies", + "diveDetailSection_preDiveChecklist_name": "Pre-dive check", + "diveDetailSection_preDiveChecklist_description": "Gekoppelde pre-dive checklistsessie", + "diveCenters_summary_topRated": "Best beoordeeld", + "diveLog_instruments_customize": "Instrumenten aanpassen", + "diveLog_instruments_customizeHint": "Schakel instrumenten in of uit. Sleep om te herschikken.", + "enum_buddyRole_buddy": "Buddy", + "enum_buddyRole_diveGuide": "Duikgids", + "enum_buddyRole_diveMaster": "Divemaster", + "enum_buddyRole_instructor": "Instructeur", + "enum_buddyRole_solo": "Solo", + "enum_buddyRole_student": "Leerling", + "equipment_addSheet_brandHint": "bijv. Scubapro", + "equipment_addSheet_brandLabel": "Merk", + "equipment_addSheet_closeTooltip": "Sluiten", + "equipment_addSheet_currencyLabel": "Valuta", + "equipment_addSheet_dateLabel": "Datum", + "equipment_addSheet_errorSnackbar": "Fout bij toevoegen van uitrusting: {error}", + "equipment_addSheet_modelHint": "bijv. MK25 EVO", + "equipment_addSheet_modelLabel": "Model", + "equipment_addSheet_nameHint": "bijv. Mijn primaire ademautomaat", + "equipment_addSheet_nameLabel": "Naam", + "equipment_addSheet_nameValidation": "Voer een naam in", + "equipment_addSheet_notesHint": "Extra notities...", + "equipment_addSheet_notesLabel": "Notities", + "equipment_addSheet_priceLabel": "Prijs", + "equipment_addSheet_purchaseInfoTitle": "Aankoopinformatie", + "equipment_addSheet_serialNumberLabel": "Serienummer", + "equipment_addSheet_serviceIntervalHint": "bijv. 365 voor jaarlijks", + "equipment_addSheet_serviceIntervalLabel": "Serviceinterval (dagen)", + "equipment_addSheet_sizeHint": "bijv. M, L, 42", + "equipment_addSheet_sizeLabel": "Maat", + "equipment_addSheet_submitButton": "Uitrusting toevoegen", + "equipment_addSheet_successSnackbar": "Uitrusting succesvol toegevoegd", + "equipment_addSheet_title": "Uitrusting toevoegen", + "equipment_addSheet_typeLabel": "Type", + "media_diveMediaSection_unlinkDialogContent": "Deze foto van de duik verwijderen? De foto blijft in je galerij staan.", + "media_diveMediaSection_unlinkDialogTitle": "Foto ontkoppelen", + "media_diveMediaSection_unlinkSuccess": "Foto ontkoppeld", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 apparaat synchroniseert vanaf een nieuwere versie van Submersion. Werk dit apparaat bij om de nieuwste wijzigingen ervan te ontvangen.} other{{count} apparaten synchroniseren vanaf een nieuwere versie van Submersion. Werk dit apparaat bij om hun nieuwste wijzigingen te ontvangen.}}", + "settings_notifications_disabled_enableButton": "Inschakelen", + "surfaceInterval_secondDive_gasAir": "(Lucht)", + "trips_detail_stat_totalBottomTime": "Totale bodemtijd", + "dashboard_photos_title": "Recente foto's", + "diveComputer_detail_cannotFilterNoSerial": "Filteren niet mogelijk: geen serienummer voor deze computer." } diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 93f0079487..3518c494d6 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "Profundidade subaquática - amostras de profundidade registadas durante os mergulhos", "settings_dataSources_appleHealth_dataTypeWaterTemp": "Temperatura da água - amostras de temperatura registadas durante os mergulhos", "settings_dataSources_appleHealth_permissionManagedInHealth": "O acesso ao HealthKit é gerido na app Saúde", - "settings_dataSources_appleHealth_permissionUnsupported": "O HealthKit não está disponível neste dispositivo" + "settings_dataSources_appleHealth_permissionUnsupported": "O HealthKit não está disponível neste dispositivo", + "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", + "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", + "divelogsSync_compare": "Comparar", + "divelogsSync_comparing": "Comparando com divelogs.de...", + "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", + "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", + "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", + "buddies_section_professionalRoles": "Funções Profissionais", + "buddies_roles_addRole": "Adicionar função", + "buddies_roles_role": "Função", + "buddies_roles_agency": "Agência", + "buddies_roles_credentialNumber": "Número de credencial", + "buddies_roles_removeTooltip": "Remover função", + "buddies_roles_emptyHint": "Adicione as credenciais de instrutor ou divemaster para reutilizá-las ao registrar certificações e cursos.", + "buddies_detail_section_professionalRoles": "Funções Profissionais", + "certifications_detail_label_level": "Nivel", + "certifications_edit_hint_certificationName": "ex., Open Water Diver", + "certifications_edit_label_certificationName": "Nome da Certificacao *", + "certifications_edit_label_level": "Nivel", + "certifications_edit_level_notSpecified": "Nao especificado", + "certifications_edit_validation_nameRequired": "Por favor, insira um nome de certificacao", + "certifications_walletCard_countPlural": "{count} certificacoes", + "certifications_walletCard_countSingular": "{count} certificacao", + "certifications_walletCard_emptyFooter": "Adicione sua primeira certificacao", + "certifications_walletCard_error": "Falha ao carregar certificacoes", + "certifications_walletCard_semanticLabel": "Carteira de Certificacoes. Toque para ver todas as certificacoes", + "certifications_walletCard_tapToAdd": "Toque para adicionar", + "certifications_walletCard_title": "Carteira de Certificacoes", + "preDive_section_title": "Verificação Pré-Mergulho", + "preDive_section_link": "Vincular uma sessão de lista de verificação", + "preDive_section_unlink": "Desvincular", + "preDive_section_run": "Executar lista de verificação pré-mergulho", + "preDive_section_noUnlinked": "Nenhuma sessão de lista de verificação desvinculada", + "diveDetailSection_preDiveChecklist_name": "Verificação Pré-Mergulho", + "diveDetailSection_preDiveChecklist_description": "Sessão de lista de verificação pré-mergulho vinculada", + "diveCenters_summary_topRated": "Mais Bem Avaliados", + "diveLog_instruments_customize": "Personalizar instrumentos", + "diveLog_instruments_customizeHint": "Ative ou desative instrumentos. Arraste para reordenar.", + "enum_buddyRole_buddy": "Dupla", + "enum_buddyRole_diveGuide": "Guia de Mergulho", + "enum_buddyRole_diveMaster": "Divemaster", + "enum_buddyRole_instructor": "Instrutor", + "enum_buddyRole_solo": "Solo", + "enum_buddyRole_student": "Aluno", + "equipment_addSheet_brandHint": "ex., Scubapro", + "equipment_addSheet_brandLabel": "Marca", + "equipment_addSheet_closeTooltip": "Fechar", + "equipment_addSheet_currencyLabel": "Moeda", + "equipment_addSheet_dateLabel": "Data", + "equipment_addSheet_errorSnackbar": "Erro ao adicionar equipamento: {error}", + "equipment_addSheet_modelHint": "ex., MK25 EVO", + "equipment_addSheet_modelLabel": "Modelo", + "equipment_addSheet_nameHint": "ex., Meu Regulador Principal", + "equipment_addSheet_nameLabel": "Nome", + "equipment_addSheet_nameValidation": "Por favor, insira um nome", + "equipment_addSheet_notesHint": "Observacoes adicionais...", + "equipment_addSheet_notesLabel": "Observacoes", + "equipment_addSheet_priceLabel": "Preco", + "equipment_addSheet_purchaseInfoTitle": "Informacoes de Compra", + "equipment_addSheet_serialNumberLabel": "Numero de Serie", + "equipment_addSheet_serviceIntervalHint": "ex., 365 para anualmente", + "equipment_addSheet_serviceIntervalLabel": "Intervalo de Manutencao (dias)", + "equipment_addSheet_sizeHint": "ex., M, G, 42", + "equipment_addSheet_sizeLabel": "Tamanho", + "equipment_addSheet_submitButton": "Adicionar Equipamento", + "equipment_addSheet_successSnackbar": "Equipamento adicionado com sucesso", + "equipment_addSheet_title": "Adicionar Equipamento", + "equipment_addSheet_typeLabel": "Tipo", + "media_diveMediaSection_unlinkDialogContent": "Remover esta foto do mergulho? A foto permanecera na sua galeria.", + "media_diveMediaSection_unlinkDialogTitle": "Desvincular Foto", + "media_diveMediaSection_unlinkSuccess": "Foto desvinculada", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 dispositivo sincroniza a partir de uma versão mais recente do Submersion. Atualize este dispositivo para receber as alterações mais recentes dele.} other{{count} dispositivos sincronizam a partir de uma versão mais recente do Submersion. Atualize este dispositivo para receber as alterações mais recentes deles.}}", + "settings_notifications_disabled_enableButton": "Ativar", + "surfaceInterval_secondDive_gasAir": "(Ar)", + "trips_detail_stat_totalBottomTime": "Tempo de Fundo Total", + "dashboard_photos_title": "Fotos recentes", + "diveComputer_detail_cannotFilterNoSerial": "Nao e possivel filtrar: sem numero de serie para este computador." } diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 305600b9d0..86d8452406 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -9949,5 +9949,117 @@ "settings_dataSources_appleHealth_dataTypeDepth": "水下深度 - 潜水过程中记录的深度采样", "settings_dataSources_appleHealth_dataTypeWaterTemp": "水温 - 潜水过程中记录的水温采样", "settings_dataSources_appleHealth_permissionManagedInHealth": "HealthKit 访问权限在“健康”App 中管理", - "settings_dataSources_appleHealth_permissionUnsupported": "此设备不支持 HealthKit" + "settings_dataSources_appleHealth_permissionUnsupported": "此设备不支持 HealthKit", + "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} 张证书", + "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 导入", + "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": "密码", + "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 账户拉取潜水日志", + "buddies_detail_section_professionalRoles": "专业角色", + "buddies_roles_addRole": "添加角色", + "buddies_roles_agency": "机构", + "buddies_roles_credentialNumber": "资质编号", + "buddies_roles_emptyHint": "添加教练或潜水长资质,以便在记录认证和课程时重复使用。", + "buddies_roles_removeTooltip": "移除角色", + "buddies_roles_role": "角色", + "buddies_section_professionalRoles": "专业角色", + "certifications_detail_label_level": "等级", + "certifications_edit_hint_certificationName": "例如,开放水域潜水员", + "certifications_edit_label_certificationName": "证书名称 *", + "certifications_edit_label_level": "等级", + "certifications_edit_level_notSpecified": "未指定", + "certifications_edit_validation_nameRequired": "请输入证书名称", + "certifications_walletCard_countPlural": "{count} 个证书", + "certifications_walletCard_countSingular": "{count} 个证书", + "certifications_walletCard_emptyFooter": "添加您的第一个证书", + "certifications_walletCard_error": "加载证书失败", + "certifications_walletCard_semanticLabel": "证书卡包。点击查看所有证书", + "certifications_walletCard_tapToAdd": "点击添加", + "certifications_walletCard_title": "证书卡包", + "preDive_section_title": "潜前检查", + "preDive_section_link": "关联检查清单记录", + "preDive_section_unlink": "取消关联", + "preDive_section_run": "执行潜前检查清单", + "preDive_section_noUnlinked": "没有未关联的检查清单记录", + "diveDetailSection_preDiveChecklist_name": "潜前检查", + "diveDetailSection_preDiveChecklist_description": "已关联的潜前检查清单记录", + "diveCenters_summary_topRated": "评分最高", + "diveLog_instruments_customize": "自定义仪表", + "diveLog_instruments_customizeHint": "开启或关闭仪表。拖动以重新排序。", + "enum_buddyRole_buddy": "潜伴", + "enum_buddyRole_diveGuide": "潜水指南", + "enum_buddyRole_diveMaster": "潜水长", + "enum_buddyRole_instructor": "教练", + "enum_buddyRole_solo": "单人", + "enum_buddyRole_student": "学生", + "equipment_addSheet_brandHint": "例如 Scubapro", + "equipment_addSheet_brandLabel": "品牌", + "equipment_addSheet_closeTooltip": "关闭", + "equipment_addSheet_currencyLabel": "货币", + "equipment_addSheet_dateLabel": "日期", + "equipment_addSheet_errorSnackbar": "添加装备出错:{error}", + "equipment_addSheet_modelHint": "例如 MK25 EVO", + "equipment_addSheet_modelLabel": "型号", + "equipment_addSheet_nameHint": "例如:我的主调节器", + "equipment_addSheet_nameLabel": "名称", + "equipment_addSheet_nameValidation": "请输入名称", + "equipment_addSheet_notesHint": "其他备注...", + "equipment_addSheet_notesLabel": "备注", + "equipment_addSheet_priceLabel": "价格", + "equipment_addSheet_purchaseInfoTitle": "购买信息", + "equipment_addSheet_serialNumberLabel": "序列编号", + "equipment_addSheet_serviceIntervalHint": "例如 365 表示每年", + "equipment_addSheet_serviceIntervalLabel": "维护间隔(天)", + "equipment_addSheet_sizeHint": "e.g., M, L, 42", + "equipment_addSheet_sizeLabel": "尺寸", + "equipment_addSheet_submitButton": "添加装备", + "equipment_addSheet_successSnackbar": "装备添加成功", + "equipment_addSheet_title": "添加装备", + "equipment_addSheet_typeLabel": "类型", + "media_diveMediaSection_unlinkDialogContent": "从此次潜水中移除此照片吗?照片将保留在您的相册中。", + "media_diveMediaSection_unlinkDialogTitle": "取消关联照片", + "media_diveMediaSection_unlinkSuccess": "照片已取消关联", + "settings_cloudSync_peerRequiresUpdate_banner": "{count, plural, =1{1 台设备正在从更新版本的 Submersion 同步。请更新此设备以接收其最新更改。} other{{count} 台设备正在从更新版本的 Submersion 同步。请更新此设备以接收它们的最新更改。}}", + "settings_notifications_disabled_enableButton": "启用", + "surfaceInterval_secondDive_gasAir": "(空气)", + "trips_detail_stat_totalBottomTime": "总计底部时间", + "dashboard_photos_title": "最近照片", + "diveComputer_detail_cannotFilterNoSerial": "无法筛选:此电脑没有序列号。" } diff --git a/test/core/data/repositories/connected_accounts_repository_test.dart b/test/core/data/repositories/connected_accounts_repository_test.dart index 980aa7e41c..8ec76060c6 100644 --- a/test/core/data/repositories/connected_accounts_repository_test.dart +++ b/test/core/data/repositories/connected_accounts_repository_test.dart @@ -64,6 +64,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'); diff --git a/test/core/database/migration_v174_connected_accounts_diver_id_test.dart b/test/core/database/migration_v174_connected_accounts_diver_id_test.dart new file mode 100644 index 0000000000..d843f3b6a7 --- /dev/null +++ b/test/core/database/migration_v174_connected_accounts_diver_id_test.dart @@ -0,0 +1,51 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +void main() { + test('v174 is the current schema version (exact-latest tripwire)', () { + expect(AppDatabase.currentSchemaVersion, 174); + expect(AppDatabase.migrationVersions, contains(174)); + }); + + test('a fresh database has connected_accounts.diver_id', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('connected_accounts')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('diver_id')); + }); + + test( + 'a database stranded before v174 gains diver_id via beforeOpen', + () async { + // Only the columns this migration touches are modelled. The beforeOpen + // backstop must add diver_id even when onUpgrade never ran. + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute(''' + CREATE TABLE connected_accounts ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + account_identifier TEXT + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('connected_accounts')") + .get(); + expect( + cols.map((c) => c.read('name')).toSet(), + contains('diver_id'), + ); + }, + ); +} 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, + ); + }); + }); +} 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); + }); +} 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..953060fb32 --- /dev/null +++ b/test/core/services/divelogs/divelogs_api_client_test.dart @@ -0,0 +1,410 @@ +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'); + }); + + 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), + ), + ); + }); + + 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); + }); + + 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 +/// 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_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())); + }); +} 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..f1c993c069 --- /dev/null +++ b/test/core/services/divelogs/divelogs_models_test.dart @@ -0,0 +1,224 @@ +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.utc(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'); + }); + + 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); + }); + }); + + 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); + }); + }); + + 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, + 'date': '2022-09-03', + 'time': '10:00:00', + 'duration': 60, + 'maxdepth': 5, + 'gearitems': [45, 62], + }); + expect(dive.gearItemIds, ['45', '62']); + }); +} 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..17ce71c9b1 --- /dev/null +++ b/test/features/divelogs_sync/data/mappers/divelogs_export_mapper_test.dart @@ -0,0 +1,157 @@ +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'; + +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('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( + maxDepth: null, + profile: const [ + DiveProfilePoint(timestamp: 0, depth: 3), + DiveProfilePoint(timestamp: 10, depth: 9.5), + ], + ), + ); + expect(json, isNotNull); + expect(json!['maxdepth'], 9.5); + }); +} 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, + ); + }); + }); +} 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')); + }); +} 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); + } +} 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); + }); +} 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..1c8629c758 --- /dev/null +++ b/test/features/divelogs_sync/domain/services/divelogs_sync_planner_test.dart @@ -0,0 +1,139 @@ +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); + 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', () { + 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); + }); + + 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/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); + }); +} 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..a2aa7b90e7 --- /dev/null +++ b/test/features/divelogs_sync/presentation/pages/divelogs_sync_page_test.dart @@ -0,0 +1,386 @@ +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/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'; +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, + ), + ); + } + + /// 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( + 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 { + 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'), + 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 { + final fallback = gearCertDefaults(req); + if (fallback != null) return fallback; + 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); + }); + + 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, + ); + }); + + 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); + }); +} 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 006f142971..7bc25efcbd 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..b983b6fb8f --- /dev/null +++ b/test/features/import_wizard/presentation/widgets/divelogs_fetch_step_test.dart @@ -0,0 +1,190 @@ +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: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: 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' || + 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}'); + }); + + 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'); + }); + }, + ); + + 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'); + }); + }, + ); +} 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..edd5dedc5f --- /dev/null +++ b/test/features/universal_import/data/services/divelogs_dive_mapper_test.dart @@ -0,0 +1,149 @@ +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.utc(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.utc(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('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), + durationSeconds: 60, + maxDepth: 5, + weightsKg: 0, + airTemp: 0, + ); + final map = mapper.mapDive(d); + expect(map.containsKey('weightUsed'), isFalse); + expect(map.containsKey('airTemp'), isFalse); + }); +} 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..66144e5c59 --- /dev/null +++ b/test/features/universal_import/data/services/divelogs_import_service_test.dart @@ -0,0 +1,238 @@ +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'; +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 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 { + 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}'); + }), + ), + ); + + 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, + '1 dive could not be read from divelogs.de and was skipped.', + ); + }); + + 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', + dateTime: DateTime.utc(2022, 9, 3, 14, 42), + entryTime: DateTime.utc(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); + }); + }); +}