From 3a43f0187df9201c2a8555d62b72d134175aa76f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:24:21 -0400 Subject: [PATCH 01/22] refactor(oauth): promote PKCE helpers from dropbox to shared core/services/oauth --- .../services/cloud_storage/dropbox/dropbox_auth_manager.dart | 2 +- .../dropbox/dropbox_pkce.dart => oauth/oauth_pkce.dart} | 2 +- .../cloud_storage/dropbox/dropbox_auth_manager_test.dart | 2 +- .../dropbox_pkce_test.dart => oauth/oauth_pkce_test.dart} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename lib/core/services/{cloud_storage/dropbox/dropbox_pkce.dart => oauth/oauth_pkce.dart} (90%) rename test/core/services/{cloud_storage/dropbox/dropbox_pkce_test.dart => oauth/oauth_pkce_test.dart} (93%) diff --git a/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart b/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart index a5c350cb4..7d27822e9 100644 --- a/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart +++ b/lib/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart' as http; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_app.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_store.dart'; -import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_pkce.dart'; +import 'package:submersion/core/services/oauth/oauth_pkce.dart'; import 'package:submersion/core/services/logger_service.dart'; /// OAuth 2 PKCE lifecycle for Dropbox: authorize-URL construction, the diff --git a/lib/core/services/cloud_storage/dropbox/dropbox_pkce.dart b/lib/core/services/oauth/oauth_pkce.dart similarity index 90% rename from lib/core/services/cloud_storage/dropbox/dropbox_pkce.dart rename to lib/core/services/oauth/oauth_pkce.dart index bd431bf31..aebd077a3 100644 --- a/lib/core/services/cloud_storage/dropbox/dropbox_pkce.dart +++ b/lib/core/services/oauth/oauth_pkce.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:crypto/crypto.dart'; -/// RFC 7636 PKCE helpers for the Dropbox OAuth flow. +/// RFC 7636 PKCE helpers, shared by OAuth flows (Dropbox, Adobe IMS). /// A 43-character code verifier: 32 random bytes, base64url, no padding. /// [random] is injectable for tests; defaults to a cryptographic source. diff --git a/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart b/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart index ff21539e6..58a3aedfd 100644 --- a/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart +++ b/test/core/services/cloud_storage/dropbox/dropbox_auth_manager_test.dart @@ -7,7 +7,7 @@ import 'package:http/testing.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart'; import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_store.dart'; -import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_pkce.dart'; +import 'package:submersion/core/services/oauth/oauth_pkce.dart'; import '../../../../support/fake_keychain_storage.dart'; diff --git a/test/core/services/cloud_storage/dropbox/dropbox_pkce_test.dart b/test/core/services/oauth/oauth_pkce_test.dart similarity index 93% rename from test/core/services/cloud_storage/dropbox/dropbox_pkce_test.dart rename to test/core/services/oauth/oauth_pkce_test.dart index d59edfd92..a5dfff9b0 100644 --- a/test/core/services/cloud_storage/dropbox/dropbox_pkce_test.dart +++ b/test/core/services/oauth/oauth_pkce_test.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_pkce.dart'; +import 'package:submersion/core/services/oauth/oauth_pkce.dart'; void main() { group('codeChallengeS256', () { From c53bf98e3af2fca3701a68e0b254d6ae806ad815 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:24:21 -0400 Subject: [PATCH 02/22] feat(lightroom): secure auth store for BYO Adobe credentials --- .../lightroom/lightroom_auth_store.dart | 94 +++++++++++++++++++ .../lightroom/lightroom_auth_store_test.dart | 65 +++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 lib/core/services/lightroom/lightroom_auth_store.dart create mode 100644 test/core/services/lightroom/lightroom_auth_store_test.dart diff --git a/lib/core/services/lightroom/lightroom_auth_store.dart b/lib/core/services/lightroom/lightroom_auth_store.dart new file mode 100644 index 000000000..4f9c40c96 --- /dev/null +++ b/lib/core/services/lightroom/lightroom_auth_store.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../secure_storage/fallback_secure_storage.dart'; + +/// Persisted Lightroom connection credentials. BYO client id: the user's +/// own Adobe Developer Console credentials live alongside the refresh +/// token so the whole connection is one atomic blob. +class LightroomAuthData { + const LightroomAuthData({ + required this.clientId, + required this.refreshToken, + this.clientSecret, + this.email, + this.displayName, + this.catalogId, + }); + + final String clientId; + final String refreshToken; + final String? clientSecret; + final String? email; + final String? displayName; + final String? catalogId; + + LightroomAuthData copyWith({ + String? refreshToken, + String? email, + String? displayName, + String? catalogId, + }) { + return LightroomAuthData( + clientId: clientId, + clientSecret: clientSecret, + refreshToken: refreshToken ?? this.refreshToken, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + catalogId: catalogId ?? this.catalogId, + ); + } + + Map toJson() => { + 'clientId': clientId, + 'clientSecret': clientSecret, + 'refreshToken': refreshToken, + 'email': email, + 'displayName': displayName, + 'catalogId': catalogId, + }; + + factory LightroomAuthData.fromJson(Map json) { + return LightroomAuthData( + clientId: json['clientId'] as String, + clientSecret: json['clientSecret'] as String?, + refreshToken: json['refreshToken'] as String, + email: json['email'] as String?, + displayName: json['displayName'] as String?, + catalogId: json['catalogId'] as String?, + ); + } +} + +/// Secure-storage persistence for the Lightroom connection. One JSON blob +/// under a single key so load/save stay atomic (S3/Dropbox precedent). +class LightroomAuthStore { + LightroomAuthStore({FlutterSecureStorage? storage}) + : _storage = FallbackSecureStorage(storage ?? const FlutterSecureStorage()); + + static const String storageKey = 'lightroom_auth'; + + final FallbackSecureStorage _storage; + + /// Null when unset or when the stored blob does not decode. A corrupt + /// blob is left in place so a decode bug cannot destroy credentials. + Future load() async { + final raw = await _storage.read(key: storageKey); + if (raw == null || raw.isEmpty) return null; + try { + return LightroomAuthData.fromJson( + jsonDecode(raw) as Map, + ); + } on FormatException { + return null; + } on TypeError { + return null; + } + } + + Future save(LightroomAuthData data) => + _storage.write(key: storageKey, value: jsonEncode(data.toJson())); + + Future clear() => _storage.delete(key: storageKey); +} diff --git a/test/core/services/lightroom/lightroom_auth_store_test.dart b/test/core/services/lightroom/lightroom_auth_store_test.dart new file mode 100644 index 000000000..c66efe105 --- /dev/null +++ b/test/core/services/lightroom/lightroom_auth_store_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; + +import '../../../support/fake_keychain_storage.dart'; + +void main() { + test('round-trips full auth data', () async { + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData( + clientId: 'cid', + clientSecret: 'sec', + refreshToken: 'rt', + email: 'a@b.c', + displayName: 'Eric', + catalogId: 'cat123', + ), + ); + final loaded = await store.load(); + expect(loaded!.clientId, 'cid'); + expect(loaded.clientSecret, 'sec'); + expect(loaded.refreshToken, 'rt'); + expect(loaded.email, 'a@b.c'); + expect(loaded.displayName, 'Eric'); + expect(loaded.catalogId, 'cat123'); + }); + + test('round-trips minimal auth data with null optionals', () async { + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save(const LightroomAuthData(clientId: 'c', refreshToken: 'r')); + final loaded = await store.load(); + expect(loaded!.clientSecret, isNull); + expect(loaded.catalogId, isNull); + }); + + test('returns null when unset and on corrupt blob', () async { + final keychain = InMemoryKeychain(); + final store = LightroomAuthStore(storage: keychain); + expect(await store.load(), isNull); + await keychain.write(key: LightroomAuthStore.storageKey, value: '{nope'); + expect(await store.load(), isNull); + // Corrupt blob is left in place, never deleted. + expect(await keychain.read(key: LightroomAuthStore.storageKey), '{nope'); + }); + + test('copyWith preserves credentials and updates labels', () { + const data = LightroomAuthData(clientId: 'c', refreshToken: 'r'); + final updated = data.copyWith( + refreshToken: 'r2', + catalogId: 'cat', + displayName: 'Name', + ); + expect(updated.clientId, 'c'); + expect(updated.refreshToken, 'r2'); + expect(updated.catalogId, 'cat'); + expect(updated.displayName, 'Name'); + }); + + test('clear removes the blob', () async { + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save(const LightroomAuthData(clientId: 'c', refreshToken: 'r')); + await store.clear(); + expect(await store.load(), isNull); + }); +} From 346c7a9a74760261e29e25a85c31dc379f1cc5e2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:25:04 -0400 Subject: [PATCH 03/22] fix(lightroom): package import in auth store --- lib/core/services/lightroom/lightroom_auth_store.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/services/lightroom/lightroom_auth_store.dart b/lib/core/services/lightroom/lightroom_auth_store.dart index 4f9c40c96..ec0a04a43 100644 --- a/lib/core/services/lightroom/lightroom_auth_store.dart +++ b/lib/core/services/lightroom/lightroom_auth_store.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import '../secure_storage/fallback_secure_storage.dart'; +import 'package:submersion/core/services/secure_storage/fallback_secure_storage.dart'; /// Persisted Lightroom connection credentials. BYO client id: the user's /// own Adobe Developer Console credentials live alongside the refresh From fb645b2810d6161638a02de100b363e7b453a688 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:26:54 -0400 Subject: [PATCH 04/22] feat(lightroom): Adobe IMS OAuth2+PKCE auth manager --- .../lightroom/adobe_ims_auth_manager.dart | 260 ++++++++++++++++++ .../adobe_ims_auth_manager_test.dart | 223 +++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 lib/core/services/lightroom/adobe_ims_auth_manager.dart create mode 100644 test/core/services/lightroom/adobe_ims_auth_manager_test.dart diff --git a/lib/core/services/lightroom/adobe_ims_auth_manager.dart b/lib/core/services/lightroom/adobe_ims_auth_manager.dart new file mode 100644 index 000000000..a04818dbb --- /dev/null +++ b/lib/core/services/lightroom/adobe_ims_auth_manager.dart @@ -0,0 +1,260 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/oauth/oauth_pkce.dart'; + +/// OAuth 2 PKCE lifecycle against Adobe IMS for the Lightroom connector: +/// authorize-URL construction, the paste-the-redirected-URL code exchange, +/// in-memory access-token caching with single-flight refresh, disconnect. +/// +/// Unlike Dropbox, IMS requires a redirect_uri. The app uses a fixed +/// documented URI; the browser lands there after consent and the user +/// pastes the full address (or just the code) back into the dialog. +/// IMS also rotates refresh tokens on refresh, so a rotated token is +/// persisted immediately. +class AdobeImsAuthManager { + AdobeImsAuthManager({ + LightroomAuthStore? store, + http.Client? httpClient, + DateTime Function()? now, + String Function()? verifierGenerator, + }) : _store = store ?? LightroomAuthStore(), + _http = httpClient ?? http.Client(), + _now = now ?? DateTime.now, + _generateVerifier = verifierGenerator ?? generateCodeVerifier; + + static final _log = LoggerService.forClass(AdobeImsAuthManager); + + static final Uri _authorizeUri = Uri.parse( + 'https://ims-na1.adobelogin.com/ims/authorize/v2', + ); + static final Uri _tokenUri = Uri.parse( + 'https://ims-na1.adobelogin.com/ims/token/v3', + ); + + /// Must match the redirect URI registered on the user's Adobe Developer + /// Console integration (BYO client id). + static const String redirectUri = 'https://submersion.app/lightroom/callback'; + + static const String scopes = + 'openid,AdobeID,lr_partner_apis,lr_partner_rendition_apis,offline_access'; + + /// Refresh slightly before IMS's expiry so an access token is never + /// presented within its final minute. + static const Duration _expiryMargin = Duration(seconds: 60); + + final LightroomAuthStore _store; + final http.Client _http; + final DateTime Function() _now; + final String Function() _generateVerifier; + + String? _pendingVerifier; + String? _pendingClientId; + String? _pendingClientSecret; + String? _accessToken; + DateTime? _accessTokenExpiry; + Future? _refreshInFlight; + + /// The authorization code from pasted connect-dialog input: either the + /// raw code or the full redirected URL carrying a `code` parameter. + /// Null when the input is empty or a URL without a code (e.g. an error + /// redirect). + static String? extractAuthorizationCode(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.contains('://')) { + final uri = Uri.tryParse(trimmed); + final code = uri?.queryParameters['code']; + return (code == null || code.isEmpty) ? null : code; + } + return trimmed; + } + + /// Generates a fresh PKCE verifier and returns the IMS authorize URL to + /// open in the system browser. + Uri beginAuthorization({required String clientId, String? clientSecret}) { + if (clientId.trim().isEmpty) { + throw const CloudStorageException( + 'Enter your Adobe client ID before connecting.', + ); + } + final verifier = _generateVerifier(); + _pendingVerifier = verifier; + _pendingClientId = clientId.trim(); + _pendingClientSecret = (clientSecret == null || clientSecret.trim().isEmpty) + ? null + : clientSecret.trim(); + return _authorizeUri.replace( + queryParameters: { + 'client_id': _pendingClientId!, + 'scope': scopes, + 'response_type': 'code', + 'redirect_uri': redirectUri, + 'code_challenge': codeChallengeS256(verifier), + 'code_challenge_method': 'S256', + }, + ); + } + + /// Exchanges the pasted redirected URL (or raw code) for tokens and + /// persists the connection. Requires a preceding [beginAuthorization] + /// in this session (the PKCE verifier is memory-only by design). + Future completeAuthorization( + String codeOrRedirectUrl, + ) async { + final verifier = _pendingVerifier; + final clientId = _pendingClientId; + if (verifier == null || clientId == null) { + throw const CloudStorageException( + 'No Adobe authorization is in progress. Reopen the connect dialog ' + 'and try again.', + ); + } + final code = extractAuthorizationCode(codeOrRedirectUrl); + if (code == null) { + throw const CloudStorageException( + 'No authorization code found. Paste the full address of the page ' + 'the browser landed on after signing in.', + ); + } + final tokens = await _requestToken({ + 'grant_type': 'authorization_code', + 'client_id': clientId, + if (_pendingClientSecret != null) 'client_secret': _pendingClientSecret!, + 'code': code, + 'code_verifier': verifier, + 'redirect_uri': redirectUri, + }); + final refreshToken = tokens['refresh_token']; + if (refreshToken is! String || refreshToken.isEmpty) { + throw const CloudStorageException( + 'Adobe did not return a refresh token. Check that your integration ' + 'includes the offline_access scope.', + ); + } + final auth = LightroomAuthData( + clientId: clientId, + clientSecret: _pendingClientSecret, + refreshToken: refreshToken, + ); + await _store.save(auth); + _pendingVerifier = null; + _pendingClientId = null; + _pendingClientSecret = null; + _cacheAccessToken(tokens); + _log.info('Lightroom connected'); + return auth; + } + + /// A currently valid access token, refreshing through the stored refresh + /// token when needed. Concurrent callers share one refresh request. + Future getAccessToken() { + final token = _accessToken; + final expiry = _accessTokenExpiry; + if (token != null && expiry != null && _now().isBefore(expiry)) { + return Future.value(token); + } + return _refreshInFlight ??= _refreshAccessToken().whenComplete(() { + _refreshInFlight = null; + }); + } + + /// Drops the cached access token so the next [getAccessToken] refreshes. + void invalidateAccessToken() { + _accessToken = null; + _accessTokenExpiry = null; + } + + /// The stored connection, or null when Lightroom is not connected. + Future loadAuth() => _store.load(); + + /// Persists updated connection data (catalog id, account labels). + Future updateAuth(LightroomAuthData data) => _store.save(data); + + /// Clears the stored connection. IMS offers no client-side revoke for + /// this flow; forgetting the refresh token ends the app's access. + Future disconnect() async { + invalidateAccessToken(); + await _store.clear(); + _log.info('Lightroom disconnected'); + } + + Future _refreshAccessToken() async { + final auth = await _store.load(); + if (auth == null) { + throw const CloudStorageException( + 'Lightroom is not connected. Connect Lightroom in Settings.', + ); + } + final tokens = await _requestToken({ + 'grant_type': 'refresh_token', + 'refresh_token': auth.refreshToken, + 'client_id': auth.clientId, + if (auth.clientSecret != null) 'client_secret': auth.clientSecret!, + }); + final rotated = tokens['refresh_token']; + if (rotated is String && + rotated.isNotEmpty && + rotated != auth.refreshToken) { + await _store.save(auth.copyWith(refreshToken: rotated)); + } + return _cacheAccessToken(tokens); + } + + /// POSTs [form] to the IMS token endpoint and returns the decoded JSON. + /// 4xx means the grant was rejected (bad code, revoked refresh token); + /// the stored blob is intentionally NOT cleared -- only an explicit + /// disconnect destroys credentials. + Future> _requestToken(Map form) async { + final http.Response response; + try { + response = await _http.post(_tokenUri, body: form); + } on Exception catch (e, st) { + throw CloudStorageException('Could not reach Adobe', e, st); + } + if (response.statusCode != 200) { + throw CloudStorageException( + response.statusCode >= 400 && response.statusCode < 500 + ? 'Adobe rejected the authorization. Reconnect Lightroom in ' + 'Settings.' + : 'Adobe authorization failed (${response.statusCode})', + _bodySummary(response), + ); + } + final Object? decoded; + try { + decoded = jsonDecode(response.body); + } on FormatException { + throw const CloudStorageException( + 'Unexpected response from Adobe authorization.', + ); + } + if (decoded is! Map || + decoded['access_token'] is! String) { + throw const CloudStorageException( + 'Unexpected response from Adobe authorization.', + ); + } + return decoded; + } + + String _cacheAccessToken(Map tokens) { + final token = tokens['access_token'] as String; + final expiresIn = tokens['expires_in']; + final seconds = expiresIn is int ? expiresIn : 3600; + _accessToken = token; + _accessTokenExpiry = _now() + .add(Duration(seconds: seconds)) + .subtract(_expiryMargin); + return token; + } + + static String _bodySummary(http.Response response) { + final body = response.body; + return body.length <= 200 ? body : body.substring(0, 200); + } +} diff --git a/test/core/services/lightroom/adobe_ims_auth_manager_test.dart b/test/core/services/lightroom/adobe_ims_auth_manager_test.dart new file mode 100644 index 000000000..74635f5cd --- /dev/null +++ b/test/core/services/lightroom/adobe_ims_auth_manager_test.dart @@ -0,0 +1,223 @@ +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/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; +import 'package:submersion/core/services/oauth/oauth_pkce.dart'; + +import '../../../support/fake_keychain_storage.dart'; + +void main() { + AdobeImsAuthManager manager(MockClient mock, {LightroomAuthStore? store}) => + AdobeImsAuthManager( + store: store ?? LightroomAuthStore(storage: InMemoryKeychain()), + httpClient: mock, + now: () => DateTime.utc(2026, 7, 11, 12), + verifierGenerator: () => 'a' * 43, + ); + + final tokenOk = http.Response( + jsonEncode({ + 'access_token': 'at1', + 'refresh_token': 'rt1', + 'expires_in': 3600, + }), + 200, + ); + + test('beginAuthorization builds IMS PKCE URL', () { + final m = manager(MockClient((_) async => http.Response('', 500))); + final uri = m.beginAuthorization(clientId: 'cid'); + expect(uri.host, 'ims-na1.adobelogin.com'); + expect(uri.path, '/ims/authorize/v2'); + expect(uri.queryParameters['client_id'], 'cid'); + expect(uri.queryParameters['response_type'], 'code'); + expect( + uri.queryParameters['redirect_uri'], + AdobeImsAuthManager.redirectUri, + ); + expect(uri.queryParameters['scope'], AdobeImsAuthManager.scopes); + expect(uri.queryParameters['code_challenge'], codeChallengeS256('a' * 43)); + expect(uri.queryParameters['code_challenge_method'], 'S256'); + }); + + test('beginAuthorization rejects an empty client id', () { + final m = manager(MockClient((_) async => tokenOk)); + expect( + () => m.beginAuthorization(clientId: ''), + throwsA(isA()), + ); + }); + + test('extractAuthorizationCode handles raw code and redirect URL', () { + expect(AdobeImsAuthManager.extractAuthorizationCode('abc123'), 'abc123'); + expect( + AdobeImsAuthManager.extractAuthorizationCode( + 'https://submersion.app/lightroom/callback?code=xyz&state=1', + ), + 'xyz', + ); + expect(AdobeImsAuthManager.extractAuthorizationCode(' '), isNull); + expect( + AdobeImsAuthManager.extractAuthorizationCode( + 'https://submersion.app/lightroom/callback?error=access_denied', + ), + isNull, + ); + }); + + test( + 'completeAuthorization exchanges code with verifier and persists', + () async { + final requests = []; + final mock = MockClient((req) async { + requests.add(req); + return tokenOk; + }); + final keychain = InMemoryKeychain(); + final m = manager(mock, store: LightroomAuthStore(storage: keychain)); + m.beginAuthorization(clientId: 'cid', clientSecret: 'sec'); + final data = await m.completeAuthorization( + 'https://submersion.app/lightroom/callback?code=thecode', + ); + expect(data.refreshToken, 'rt1'); + expect(data.clientId, 'cid'); + expect(data.clientSecret, 'sec'); + final body = Uri.splitQueryString(requests.single.body); + expect(body['grant_type'], 'authorization_code'); + expect(body['code'], 'thecode'); + expect(body['code_verifier'], 'a' * 43); + expect(body['client_id'], 'cid'); + expect(body['client_secret'], 'sec'); + expect(body['redirect_uri'], AdobeImsAuthManager.redirectUri); + expect(await m.loadAuth(), isNotNull); + }, + ); + + test('completeAuthorization throws without beginAuthorization', () { + final m = manager(MockClient((_) async => tokenOk)); + expect(m.completeAuthorization('c'), throwsA(isA())); + }); + + test('completeAuthorization rejects input without a code', () async { + final m = manager(MockClient((_) async => tokenOk)); + m.beginAuthorization(clientId: 'cid'); + await expectLater( + m.completeAuthorization( + 'https://submersion.app/lightroom/callback?error=denied', + ), + throwsA(isA()), + ); + }); + + test( + 'getAccessToken refreshes with stored token, caches, single-flights', + () async { + var calls = 0; + final mock = MockClient((req) async { + calls++; + final body = Uri.splitQueryString(req.body); + expect(body['grant_type'], 'refresh_token'); + expect(body['refresh_token'], 'rt0'); + expect(body['client_id'], 'cid'); + return http.Response( + jsonEncode({'access_token': 'at1', 'expires_in': 3600}), + 200, + ); + }); + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt0'), + ); + final m = manager(mock, store: store); + final results = await Future.wait([ + m.getAccessToken(), + m.getAccessToken(), + ]); + expect(results, ['at1', 'at1']); + expect(calls, 1); + expect(await m.getAccessToken(), 'at1'); + expect(calls, 1); + }, + ); + + test('rotated refresh token from IMS is persisted', () async { + final mock = MockClient( + (_) async => http.Response( + jsonEncode({ + 'access_token': 'at2', + 'refresh_token': 'rt-rotated', + 'expires_in': 3600, + }), + 200, + ), + ); + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt0'), + ); + final m = manager(mock, store: store); + await m.getAccessToken(); + expect((await store.load())!.refreshToken, 'rt-rotated'); + }); + + test('4xx refresh throws but preserves stored blob', () async { + final mock = MockClient((_) async => http.Response('denied', 400)); + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt0'), + ); + final m = manager(mock, store: store); + await expectLater( + m.getAccessToken(), + throwsA(isA()), + ); + expect(await store.load(), isNotNull); + }); + + test( + 'failed refresh clears in-flight guard so a retry can recover', + () async { + var calls = 0; + final mock = MockClient((_) async { + calls++; + if (calls == 1) return http.Response('oops', 503); + return http.Response( + jsonEncode({'access_token': 'at9', 'expires_in': 3600}), + 200, + ); + }); + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt0'), + ); + final m = manager(mock, store: store); + await expectLater( + m.getAccessToken(), + throwsA(isA()), + ); + expect(await m.getAccessToken(), 'at9'); + }, + ); + + test('getAccessToken throws when not connected', () { + final m = manager(MockClient((_) async => tokenOk)); + expect(m.getAccessToken(), throwsA(isA())); + }); + + test('disconnect clears store', () async { + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt0'), + ); + final m = manager( + MockClient((_) async => http.Response('', 200)), + store: store, + ); + await m.disconnect(); + expect(await store.load(), isNull); + }); +} From d88d9e4645ee4ef82b4fdcbc1a435fe89d4a584a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:27:48 -0400 Subject: [PATCH 05/22] style(lightroom): null-aware map elements in token forms --- lib/core/services/lightroom/adobe_ims_auth_manager.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/services/lightroom/adobe_ims_auth_manager.dart b/lib/core/services/lightroom/adobe_ims_auth_manager.dart index a04818dbb..900f67b41 100644 --- a/lib/core/services/lightroom/adobe_ims_auth_manager.dart +++ b/lib/core/services/lightroom/adobe_ims_auth_manager.dart @@ -124,7 +124,7 @@ class AdobeImsAuthManager { final tokens = await _requestToken({ 'grant_type': 'authorization_code', 'client_id': clientId, - if (_pendingClientSecret != null) 'client_secret': _pendingClientSecret!, + 'client_secret': ?_pendingClientSecret, 'code': code, 'code_verifier': verifier, 'redirect_uri': redirectUri, @@ -194,7 +194,7 @@ class AdobeImsAuthManager { 'grant_type': 'refresh_token', 'refresh_token': auth.refreshToken, 'client_id': auth.clientId, - if (auth.clientSecret != null) 'client_secret': auth.clientSecret!, + 'client_secret': ?auth.clientSecret, }); final rotated = tokens['refresh_token']; if (rotated is String && From a531834491a960a16a0a5aeabbcd2935d07dc99a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:29:44 -0400 Subject: [PATCH 06/22] feat(lightroom): partner API client with abuse-guard stripping and pagination --- .../lightroom/lightroom_api_client.dart | 213 ++++++++++++++ .../services/lightroom/lightroom_models.dart | 77 +++++ .../lightroom/lightroom_api_client_test.dart | 272 ++++++++++++++++++ 3 files changed, 562 insertions(+) create mode 100644 lib/core/services/lightroom/lightroom_api_client.dart create mode 100644 lib/core/services/lightroom/lightroom_models.dart create mode 100644 test/core/services/lightroom/lightroom_api_client_test.dart diff --git a/lib/core/services/lightroom/lightroom_api_client.dart b/lib/core/services/lightroom/lightroom_api_client.dart new file mode 100644 index 000000000..2a3939e60 --- /dev/null +++ b/lib/core/services/lightroom/lightroom_api_client.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_models.dart'; + +/// A non-2xx response from the Lightroom partner API. Carries the status +/// code so callers can distinguish auth failures (401) from transient +/// errors. +class LightroomApiException implements Exception { + const LightroomApiException(this.statusCode, this.message); + + final int statusCode; + final String message; + + @override + String toString() => message; +} + +/// REST client for the Lightroom partner API (https://lr.adobe.io). +/// +/// Every JSON response body from this API is prefixed with the abuse-guard +/// string `while (1) {}` which must be stripped before decoding. +class LightroomApiClient { + LightroomApiClient({ + required AdobeImsAuthManager auth, + http.Client? httpClient, + }) : _auth = auth, + _http = httpClient ?? http.Client(); + + static const String baseUrl = 'https://lr.adobe.io'; + + static const String _abuseGuard = 'while (1) {}'; + + final AdobeImsAuthManager _auth; + final http.Client _http; + + static String stripAbuseGuard(String body) { + var s = body; + if (s.startsWith(_abuseGuard)) { + s = s.substring(_abuseGuard.length); + } + return s.trimLeft(); + } + + /// The web URL of an asset on lightroom.adobe.com ("Open in Lightroom"). + static String assetWebUrl(String catalogId, String assetId) => + 'https://lightroom.adobe.com/libraries/$catalogId/assets/$assetId'; + + Future getAccount() async { + final json = await _getJson(Uri.parse('$baseUrl/v2/account')); + return LightroomAccount( + id: json['id'] as String, + fullName: json['full_name'] as String?, + email: json['email'] as String?, + ); + } + + Future getCatalogId() async { + final json = await _getJson(Uri.parse('$baseUrl/v2/catalog')); + return json['id'] as String; + } + + Future> listAlbums(String catalogId) async { + final albums = []; + Uri? uri = Uri.parse( + '$baseUrl/v2/catalogs/$catalogId/albums', + ).replace(queryParameters: {'subtype': 'collection'}); + while (uri != null) { + final json = await _getJson(uri); + for (final resource in _resources(json)) { + final payload = + resource['payload'] as Map? ?? const {}; + albums.add( + LightroomAlbum( + id: resource['id'] as String, + name: payload['name'] as String? ?? 'Untitled', + ), + ); + } + final next = _nextUrl(json); + uri = next == null ? null : Uri.parse(next); + } + return albums; + } + + /// One page of catalog assets (images and videos), capture-date filtered. + /// Pass [nextUrl] from a previous page to continue; it wins over the + /// filter parameters. + Future listAssets( + String catalogId, { + DateTime? capturedAfter, + DateTime? capturedBefore, + String? nextUrl, + }) async { + final uri = nextUrl != null + ? Uri.parse(nextUrl) + : Uri.parse('$baseUrl/v2/catalogs/$catalogId/assets').replace( + queryParameters: { + 'subtype': 'image;video', + 'captured_after': ?_isoWallClock(capturedAfter), + 'captured_before': ?_isoWallClock(capturedBefore), + }, + ); + final json = await _getJson(uri); + return LightroomAssetPage( + assets: [ + for (final resource in _resources(json)) + LightroomAsset.fromResource(resource), + ], + nextUrl: _nextUrl(json), + ); + } + + /// One page of an album's assets with the asset entity embedded. Rows + /// without an embedded asset are skipped. + Future listAlbumAssets( + String catalogId, + String albumId, { + String? nextUrl, + }) async { + final uri = nextUrl != null + ? Uri.parse(nextUrl) + : Uri.parse( + '$baseUrl/v2/catalogs/$catalogId/albums/$albumId/assets', + ).replace(queryParameters: {'embed': 'asset'}); + final json = await _getJson(uri); + return LightroomAssetPage( + assets: [ + for (final resource in _resources(json)) + if (resource['asset'] is Map) + LightroomAsset.fromResource( + resource['asset']! as Map, + ), + ], + nextUrl: _nextUrl(json), + ); + } + + /// Raw rendition bytes. [size] is a Lightroom rendition type such as + /// `2048` or `thumbnail2x`. Videos get poster-frame renditions. + Future getRendition({ + required String catalogId, + required String assetId, + required String size, + }) async { + final uri = Uri.parse( + '$baseUrl/v2/catalogs/$catalogId/assets/$assetId/renditions/$size', + ); + final response = await _http.get(uri, headers: await _headers()); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _errorFor(response.statusCode); + } + return response.bodyBytes; + } + + Future> _headers() async { + final token = await _auth.getAccessToken(); + final auth = await _auth.loadAuth(); + return { + 'Authorization': 'Bearer $token', + 'X-API-Key': auth?.clientId ?? '', + }; + } + + Future> _getJson(Uri uri) async { + final response = await _http.get(uri, headers: await _headers()); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _errorFor(response.statusCode); + } + final decoded = jsonDecode(stripAbuseGuard(response.body)); + if (decoded is! Map) { + throw const LightroomApiException(0, 'Unexpected Lightroom API response'); + } + return decoded; + } + + LightroomApiException _errorFor(int statusCode) { + if (statusCode == 401) { + return const LightroomApiException( + 401, + 'Adobe rejected the credentials. Reconnect Lightroom in Settings.', + ); + } + return LightroomApiException(statusCode, 'Lightroom API error $statusCode'); + } + + List> _resources(Map json) { + final raw = json['resources']; + if (raw is! List) return const []; + return raw.whereType>().toList(); + } + + String? _nextUrl(Map json) { + final links = json['links']; + if (links is! Map) return null; + final next = links['next']; + if (next is! Map) return null; + final href = next['href']; + if (href is! String || href.isEmpty) return null; + final uri = Uri.parse(href); + return uri.hasScheme ? href : '$baseUrl$href'; + } + + /// Wall-clock timestamps formatted without a zone designator, matching + /// how Lightroom reports capture dates. + String? _isoWallClock(DateTime? t) { + if (t == null) return null; + return t.toIso8601String().replaceFirst('Z', ''); + } +} diff --git a/lib/core/services/lightroom/lightroom_models.dart b/lib/core/services/lightroom/lightroom_models.dart new file mode 100644 index 000000000..df0229a9e --- /dev/null +++ b/lib/core/services/lightroom/lightroom_models.dart @@ -0,0 +1,77 @@ +import 'package:submersion/core/util/wall_clock_utc.dart'; + +/// The signed-in Adobe account, from GET /v2/account. +class LightroomAccount { + const LightroomAccount({required this.id, this.fullName, this.email}); + + final String id; + final String? fullName; + final String? email; +} + +/// A Lightroom album (subtype `collection`). +class LightroomAlbum { + const LightroomAlbum({required this.id, required this.name}); + + final String id; + final String name; +} + +/// One catalog asset as returned by the assets endpoints. Parsing is +/// tolerant: any missing payload field becomes null rather than an error, +/// because the API omits blocks that do not apply to an asset. +class LightroomAsset { + const LightroomAsset({ + required this.id, + required this.subtype, + this.captureDate, + this.fileName, + this.latitude, + this.longitude, + this.videoDurationSeconds, + }); + + final String id; + final String subtype; + + /// Wall-clock UTC per the app-wide convention; null when Lightroom + /// reports the unknown-capture-time sentinel (a `0000-...` date). + final DateTime? captureDate; + final String? fileName; + final double? latitude; + final double? longitude; + final int? videoDurationSeconds; + + bool get isVideo => subtype == 'video'; + + factory LightroomAsset.fromResource(Map resource) { + final payload = resource['payload'] as Map? ?? const {}; + final rawCapture = payload['captureDate'] as String?; + DateTime? captureDate; + if (rawCapture != null && !rawCapture.startsWith('0000')) { + captureDate = parseExternalDateAsWallClockUtc(rawCapture); + } + final importSource = + payload['importSource'] as Map? ?? const {}; + final location = payload['location'] as Map? ?? const {}; + final video = payload['video'] as Map? ?? const {}; + return LightroomAsset( + id: resource['id'] as String, + subtype: resource['subtype'] as String? ?? 'image', + captureDate: captureDate, + fileName: importSource['fileName'] as String?, + latitude: (location['latitude'] as num?)?.toDouble(), + longitude: (location['longitude'] as num?)?.toDouble(), + videoDurationSeconds: (video['duration'] as num?)?.round(), + ); + } +} + +/// One page of an asset listing plus the absolute URL of the next page +/// (null on the last page). +class LightroomAssetPage { + const LightroomAssetPage({required this.assets, this.nextUrl}); + + final List assets; + final String? nextUrl; +} diff --git a/test/core/services/lightroom/lightroom_api_client_test.dart b/test/core/services/lightroom/lightroom_api_client_test.dart new file mode 100644 index 000000000..c4f9f0159 --- /dev/null +++ b/test/core/services/lightroom/lightroom_api_client_test.dart @@ -0,0 +1,272 @@ +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/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; +import 'package:submersion/core/services/lightroom/lightroom_models.dart'; + +import '../../../support/fake_keychain_storage.dart'; + +const _guard = 'while (1) {}'; + +void main() { + Future client( + Future Function(http.Request) handler, + ) async { + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt'), + ); + final auth = AdobeImsAuthManager( + store: store, + httpClient: MockClient((req) async { + if (req.url.host == 'ims-na1.adobelogin.com') { + return http.Response( + jsonEncode({'access_token': 'at', 'expires_in': 3600}), + 200, + ); + } + return handler(req); + }), + ); + return LightroomApiClient(auth: auth, httpClient: MockClient(handler)); + } + + test('stripAbuseGuard removes the prefix and leaves other bodies alone', () { + expect(LightroomApiClient.stripAbuseGuard('$_guard{"a":1}'), '{"a":1}'); + expect(LightroomApiClient.stripAbuseGuard('$_guard\n{"a":1}'), '{"a":1}'); + expect(LightroomApiClient.stripAbuseGuard('{"a":1}'), '{"a":1}'); + }); + + test('getAccount sends bearer and api key headers', () async { + late http.Request captured; + final c = await client((req) async { + captured = req; + return http.Response( + '$_guard${jsonEncode({'id': 'acc1', 'full_name': 'Eric G', 'email': 'e@g.c'})}', + 200, + ); + }); + final account = await c.getAccount(); + expect(account.id, 'acc1'); + expect(account.fullName, 'Eric G'); + expect(account.email, 'e@g.c'); + expect(captured.url.toString(), 'https://lr.adobe.io/v2/account'); + expect(captured.headers['Authorization'], 'Bearer at'); + expect(captured.headers['X-API-Key'], 'cid'); + }); + + test('getCatalogId returns the catalog id', () async { + final c = await client( + (req) async => http.Response('$_guard${jsonEncode({'id': 'cat9'})}', 200), + ); + expect(await c.getCatalogId(), 'cat9'); + }); + + test( + 'listAssets parses assets, pagination link, and capture params', + () async { + late http.Request captured; + final fixture = + _guard + + jsonEncode({ + 'resources': [ + { + 'id': 'asset1', + 'subtype': 'image', + 'payload': { + 'captureDate': '2026-07-01T10:15:00', + 'importSource': {'fileName': 'IMG_1.jpg'}, + 'location': {'latitude': 4.5, 'longitude': 55.5}, + }, + }, + { + 'id': 'asset2', + 'subtype': 'video', + 'payload': { + 'captureDate': '2026-07-01T10:20:00', + 'importSource': {'fileName': 'MOV_1.mp4'}, + 'video': {'duration': 12.5}, + }, + }, + { + 'id': 'asset3', + 'subtype': 'image', + 'payload': {'captureDate': '0000-00-00T00:00:00'}, + }, + ], + 'links': { + 'next': {'href': '/v2/catalogs/cat1/assets?name_after=asset2'}, + }, + }); + final c = await client((req) async { + captured = req; + return http.Response(fixture, 200); + }); + final page = await c.listAssets( + 'cat1', + capturedAfter: DateTime.utc(2026, 7, 1, 9), + capturedBefore: DateTime.utc(2026, 7, 1, 12), + ); + expect(captured.url.path, '/v2/catalogs/cat1/assets'); + expect(captured.url.queryParameters['subtype'], 'image;video'); + expect( + captured.url.queryParameters['captured_after'], + '2026-07-01T09:00:00.000', + ); + expect( + captured.url.queryParameters['captured_before'], + '2026-07-01T12:00:00.000', + ); + expect(page.assets, hasLength(3)); + final a1 = page.assets[0]; + expect(a1.id, 'asset1'); + expect(a1.captureDate, DateTime.utc(2026, 7, 1, 10, 15)); + expect(a1.fileName, 'IMG_1.jpg'); + expect(a1.latitude, 4.5); + expect(a1.longitude, 55.5); + expect(a1.isVideo, isFalse); + final a2 = page.assets[1]; + expect(a2.isVideo, isTrue); + expect(a2.videoDurationSeconds, 13); + expect(page.assets[2].captureDate, isNull); + expect( + page.nextUrl, + 'https://lr.adobe.io/v2/catalogs/cat1/assets?name_after=asset2', + ); + }, + ); + + test('listAssets with nextUrl requests it verbatim', () async { + late http.Request captured; + final c = await client((req) async { + captured = req; + return http.Response('$_guard${jsonEncode({'resources': []})}', 200); + }); + final page = await c.listAssets( + 'cat1', + nextUrl: 'https://lr.adobe.io/v2/catalogs/cat1/assets?name_after=x', + ); + expect( + captured.url.toString(), + 'https://lr.adobe.io/v2/catalogs/cat1/assets?name_after=x', + ); + expect(page.assets, isEmpty); + expect(page.nextUrl, isNull); + }); + + test('listAlbumAssets unwraps embedded assets', () async { + late http.Request captured; + final fixture = + _guard + + jsonEncode({ + 'resources': [ + { + 'id': 'albumasset1', + 'asset': { + 'id': 'asset7', + 'subtype': 'image', + 'payload': { + 'captureDate': '2026-07-02T08:00:00', + 'importSource': {'fileName': 'IMG_7.jpg'}, + }, + }, + }, + {'id': 'albumasset2'}, + ], + }); + final c = await client((req) async { + captured = req; + return http.Response(fixture, 200); + }); + final page = await c.listAlbumAssets('cat1', 'al1'); + expect(captured.url.path, '/v2/catalogs/cat1/albums/al1/assets'); + expect(captured.url.queryParameters['embed'], 'asset'); + expect(page.assets, hasLength(1)); + expect(page.assets.single.id, 'asset7'); + }); + + test('listAlbums follows pagination and reads names', () async { + var call = 0; + final c = await client((req) async { + call++; + if (call == 1) { + return http.Response( + _guard + + jsonEncode({ + 'resources': [ + { + 'id': 'al1', + 'payload': {'name': 'Diving'}, + }, + ], + 'links': { + 'next': {'href': '/v2/catalogs/cat1/albums?after=al1'}, + }, + }), + 200, + ); + } + return http.Response( + _guard + + jsonEncode({ + 'resources': [ + {'id': 'al2', 'payload': {}}, + ], + }), + 200, + ); + }); + final albums = await c.listAlbums('cat1'); + expect(albums, hasLength(2)); + expect(albums[0].name, 'Diving'); + expect(albums[1].name, 'Untitled'); + }); + + test('getRendition returns raw bytes and throws typed 404', () async { + final bytes = Uint8List.fromList([0xFF, 0xD8, 0x01]); + final c = await client((req) async { + if (req.url.path.endsWith('/renditions/2048')) { + return http.Response.bytes(bytes, 200); + } + return http.Response('nope', 404); + }); + expect( + await c.getRendition(catalogId: 'cat1', assetId: 'a1', size: '2048'), + bytes, + ); + await expectLater( + c.getRendition(catalogId: 'cat1', assetId: 'a1', size: 'thumbnail2x'), + throwsA( + isA().having((e) => e.statusCode, 'status', 404), + ), + ); + }); + + test('401 throws a reconnect-worded LightroomApiException', () async { + final c = await client((req) async => http.Response('denied', 401)); + await expectLater( + c.getCatalogId(), + throwsA( + isA() + .having((e) => e.statusCode, 'status', 401) + .having( + (e) => e.message.toLowerCase(), + 'msg', + contains('reconnect'), + ), + ), + ); + }); + + test('assetWebUrl builds the lightroom.adobe.com link', () { + expect( + LightroomApiClient.assetWebUrl('cat1', 'a1'), + 'https://lightroom.adobe.com/libraries/cat1/assets/a1', + ); + }); +} From 0ff8aebeb7cd207240e4053e5ed50499bfb67554 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:30:17 -0400 Subject: [PATCH 07/22] style(lightroom): drop unused test import --- test/core/services/lightroom/lightroom_api_client_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/core/services/lightroom/lightroom_api_client_test.dart b/test/core/services/lightroom/lightroom_api_client_test.dart index c4f9f0159..48afdc264 100644 --- a/test/core/services/lightroom/lightroom_api_client_test.dart +++ b/test/core/services/lightroom/lightroom_api_client_test.dart @@ -7,7 +7,6 @@ import 'package:http/testing.dart'; import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; -import 'package:submersion/core/services/lightroom/lightroom_models.dart'; import '../../../support/fake_keychain_storage.dart'; From 3dd136bb36c8f4b439e13e916c50ae206276887f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:32:08 -0400 Subject: [PATCH 08/22] feat(media): connector accounts repository (first consumer of the v72 table) --- .../connector_accounts_repository.dart | 110 ++++++++++++++++++ .../domain/entities/connector_account.dart | 41 +++++++ .../connector_accounts_repository_test.dart | 95 +++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 lib/features/media/data/repositories/connector_accounts_repository.dart create mode 100644 lib/features/media/domain/entities/connector_account.dart create mode 100644 test/features/media/data/repositories/connector_accounts_repository_test.dart diff --git a/lib/features/media/data/repositories/connector_accounts_repository.dart b/lib/features/media/data/repositories/connector_accounts_repository.dart new file mode 100644 index 000000000..2101daf38 --- /dev/null +++ b/lib/features/media/data/repositories/connector_accounts_repository.dart @@ -0,0 +1,110 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; + +/// CRUD for external media service connections (`connector_accounts`). +/// The table is per-device and never synced; secrets live in secure +/// storage behind `credentialsRef`. +class ConnectorAccountsRepository { + AppDatabase get _db => DatabaseService.instance.database; + final _uuid = const Uuid(); + + /// The account for [connectorType], newest first when several exist, + /// or null when the service is not connected on this device. + Future getByType(String connectorType) async { + final query = _db.select(_db.connectorAccounts) + ..where((t) => t.connectorType.equals(connectorType)) + ..orderBy([(t) => OrderingTerm.desc(t.addedAt)]) + ..limit(1); + final row = await query.getSingleOrNull(); + return row == null ? null : _toDomain(row); + } + + Future create({ + required String connectorType, + required String displayName, + required String credentialsRef, + String? accountIdentifier, + String? baseUrl, + DateTime? addedAt, + }) async { + final id = _uuid.v4(); + final added = addedAt ?? DateTime.now(); + await _db + .into(_db.connectorAccounts) + .insert( + ConnectorAccountsCompanion.insert( + id: id, + connectorType: connectorType, + displayName: displayName, + credentialsRef: credentialsRef, + accountIdentifier: Value(accountIdentifier), + baseUrl: Value(baseUrl), + addedAt: added.millisecondsSinceEpoch, + ), + ); + return domain.ConnectorAccount( + id: id, + connectorType: connectorType, + displayName: displayName, + credentialsRef: credentialsRef, + accountIdentifier: accountIdentifier, + baseUrl: baseUrl, + addedAt: added, + ); + } + + Future updateDisplay( + String id, { + String? displayName, + String? accountIdentifier, + }) async { + await (_db.update( + _db.connectorAccounts, + )..where((t) => t.id.equals(id))).write( + ConnectorAccountsCompanion( + displayName: displayName == null + ? const Value.absent() + : Value(displayName), + accountIdentifier: accountIdentifier == null + ? const Value.absent() + : Value(accountIdentifier), + ), + ); + } + + Future touchLastUsed(String id) async { + await (_db.update( + _db.connectorAccounts, + )..where((t) => t.id.equals(id))).write( + ConnectorAccountsCompanion( + lastUsedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } + + Future delete(String id) async { + await (_db.delete( + _db.connectorAccounts, + )..where((t) => t.id.equals(id))).go(); + } + + domain.ConnectorAccount _toDomain(ConnectorAccount row) { + return domain.ConnectorAccount( + id: row.id, + connectorType: row.connectorType, + displayName: row.displayName, + baseUrl: row.baseUrl, + accountIdentifier: row.accountIdentifier, + credentialsRef: row.credentialsRef, + addedAt: DateTime.fromMillisecondsSinceEpoch(row.addedAt, isUtc: true), + lastUsedAt: row.lastUsedAt == null + ? null + : DateTime.fromMillisecondsSinceEpoch(row.lastUsedAt!, isUtc: true), + ); + } +} diff --git a/lib/features/media/domain/entities/connector_account.dart b/lib/features/media/domain/entities/connector_account.dart new file mode 100644 index 000000000..e7cd45236 --- /dev/null +++ b/lib/features/media/domain/entities/connector_account.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; + +/// A configured external media service connection (Lightroom, Immich, ...). +/// Per-device: the backing table is intentionally not synced, each device +/// signs in independently. `credentialsRef` points at a secure-storage +/// entry; the secret itself never touches the database. +class ConnectorAccount extends Equatable { + const ConnectorAccount({ + required this.id, + required this.connectorType, + required this.displayName, + required this.credentialsRef, + required this.addedAt, + this.baseUrl, + this.accountIdentifier, + this.lastUsedAt, + }); + + final String id; + final String connectorType; + final String displayName; + final String? baseUrl; + + /// Service-specific account scope. For Lightroom this is the catalog id. + final String? accountIdentifier; + final String credentialsRef; + final DateTime addedAt; + final DateTime? lastUsedAt; + + @override + List get props => [ + id, + connectorType, + displayName, + baseUrl, + accountIdentifier, + credentialsRef, + addedAt, + lastUsedAt, + ]; +} diff --git a/test/features/media/data/repositories/connector_accounts_repository_test.dart b/test/features/media/data/repositories/connector_accounts_repository_test.dart new file mode 100644 index 000000000..86399f391 --- /dev/null +++ b/test/features/media/data/repositories/connector_accounts_repository_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/repositories/connector_accounts_repository.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late ConnectorAccountsRepository repository; + + setUp(() async { + await setUpTestDatabase(); + repository = ConnectorAccountsRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + test('create then getByType round-trips all fields', () async { + final created = await repository.create( + connectorType: 'lightroom', + displayName: 'Eric G', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat123', + ); + expect(created.id, isNotEmpty); + + final loaded = await repository.getByType('lightroom'); + expect(loaded, isNotNull); + expect(loaded!.id, created.id); + expect(loaded.connectorType, 'lightroom'); + expect(loaded.displayName, 'Eric G'); + expect(loaded.credentialsRef, 'lightroom_auth'); + expect(loaded.accountIdentifier, 'cat123'); + expect(loaded.lastUsedAt, isNull); + }); + + test('getByType returns null when absent', () async { + expect(await repository.getByType('lightroom'), isNull); + }); + + test('getByType picks the newest of two accounts of the same type', () async { + await repository.create( + connectorType: 'lightroom', + displayName: 'Old', + credentialsRef: 'ref', + addedAt: DateTime.utc(2026, 1, 1), + ); + await repository.create( + connectorType: 'lightroom', + displayName: 'New', + credentialsRef: 'ref', + addedAt: DateTime.utc(2026, 6, 1), + ); + final loaded = await repository.getByType('lightroom'); + expect(loaded!.displayName, 'New'); + }); + + test('touchLastUsed sets lastUsedAt', () async { + final created = await repository.create( + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'ref', + ); + await repository.touchLastUsed(created.id); + final loaded = await repository.getByType('lightroom'); + expect(loaded!.lastUsedAt, isNotNull); + }); + + test('updateDisplay changes labels only', () async { + final created = await repository.create( + connectorType: 'lightroom', + displayName: 'Before', + credentialsRef: 'ref', + ); + await repository.updateDisplay( + created.id, + displayName: 'After', + accountIdentifier: 'cat9', + ); + final loaded = await repository.getByType('lightroom'); + expect(loaded!.displayName, 'After'); + expect(loaded.accountIdentifier, 'cat9'); + expect(loaded.credentialsRef, 'ref'); + }); + + test('delete removes the account', () async { + final created = await repository.create( + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'ref', + ); + await repository.delete(created.id); + expect(await repository.getByType('lightroom'), isNull); + }); +} From f383b7b8dd4eb57e95861c8baa349d77748e5dad Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:38:04 -0400 Subject: [PATCH 09/22] feat(media): connector columns on pending suggestions + suggestion CRUD and remote-asset dedup queries (schema v104) --- lib/core/database/database.dart | 51 +++++++- .../data/repositories/media_repository.dart | 107 ++++++++++++++++ .../media/domain/entities/media_item.dart | 12 +- .../migration_v105_profile_heading_test.dart | 6 +- ...ation_v106_connector_suggestions_test.dart | 106 ++++++++++++++++ .../media_repository_suggestions_test.dart | 119 ++++++++++++++++++ 6 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 test/core/database/migration_v106_connector_suggestions_test.dart create mode 100644 test/features/media/data/media_repository_suggestions_test.dart diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 1509515fd..a20a9b3aa 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -952,7 +952,11 @@ class MediaSpecies extends Table { Set get primaryKey => {id}; } -/// Pending photo suggestions for background scan feature +/// Pending photo suggestions for background scan feature. +/// +/// v106: connector suggestions (Lightroom) reuse this table. For those +/// rows `connectorAccountId`/`remoteAssetId` are set and the remote asset +/// id is mirrored into the NOT NULL `platformAssetId` key column. class PendingPhotoSuggestions extends Table { TextColumn get id => text()(); TextColumn get diveId => @@ -962,6 +966,8 @@ class PendingPhotoSuggestions extends Table { TextColumn get thumbnailPath => text().nullable()(); BoolColumn get dismissed => boolean().withDefault(const Constant(false))(); IntColumn get createdAt => integer()(); + TextColumn get connectorAccountId => text().nullable()(); + TextColumn get remoteAssetId => text().nullable()(); @override Set get primaryKey => {id}; @@ -2153,7 +2159,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 = 105; + static const int currentSchemaVersion = 106; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -2262,8 +2268,35 @@ class AppDatabase extends _$AppDatabase { 103, 104, 105, + 106, ]; + /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom + /// auto-linking). Called from the v106 onUpgrade block and from the + /// beforeOpen backstop so a parallel-branch schema-version collision + /// cannot strand a database without them. + Future _assertConnectorSuggestionColumns() async { + final cols = await customSelect( + "PRAGMA table_info('pending_photo_suggestions')", + ).get(); + // An empty PRAGMA result means the table itself is absent (only + // possible in minimal test fixtures); skip the ALTERs rather than fail. + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('connector_account_id')) { + await customStatement( + 'ALTER TABLE pending_photo_suggestions ' + 'ADD COLUMN connector_account_id TEXT', + ); + } + if (!names.contains('remote_asset_id')) { + await customStatement( + 'ALTER TABLE pending_photo_suggestions ' + 'ADD COLUMN remote_asset_id TEXT', + ); + } + } + /// Idempotent DDL for the v103 media store objects. Called from the v103 /// onUpgrade block and from the beforeOpen backstop so a parallel-branch /// schema-version collision cannot strand a database without them. @@ -5238,6 +5271,16 @@ class AppDatabase extends _$AppDatabase { } } if (from < 105) await reportProgress(); + if (from < 106) { + // Lightroom auto-linking: connector identity on pending photo + // suggestions. PRAGMA-guarded ALTERs keep this idempotent; the + // beforeOpen backstop re-asserts it against parallel-branch + // schema-version collisions (v104 weight planner and v105 + // heading both renumbered this block already, the same disease + // the v103 comment documents). + await _assertConnectorSuggestionColumns(); + } + if (from < 106) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -5247,6 +5290,10 @@ class AppDatabase extends _$AppDatabase { // self-guarding when the media table is absent). await _assertMediaStoreSchema(); + // v106 backstop: re-assert connector-suggestion columns (the helper + // is self-guarding when the suggestions table is absent). + await _assertConnectorSuggestionColumns(); + // Built-in dive types are reference data: identical on every device and // undeletable through DiveTypeRepository. Nothing else restores them -- // the seed runs only in onCreate and the one-shot v93 step -- yet a diff --git a/lib/features/media/data/repositories/media_repository.dart b/lib/features/media/data/repositories/media_repository.dart index bc3ba0e6f..948d07e99 100644 --- a/lib/features/media/data/repositories/media_repository.dart +++ b/lib/features/media/data/repositories/media_repository.dart @@ -733,6 +733,113 @@ class MediaRepository { } } + /// Remote asset ids of every connector-sourced media row. Media rows sync + /// via HLC, so this covers rows created on other devices too: it is the + /// scan-time dedup set that keeps a second connected device from + /// re-creating the same Lightroom links. + Future> getConnectorRemoteAssetIds() async { + final remoteAssetId = _db.media.remoteAssetId; + final query = _db.selectOnly(_db.media) + ..addColumns([remoteAssetId]) + ..where( + _db.media.sourceType.equals(MediaSourceType.serviceConnector.name) & + remoteAssetId.isNotNull(), + ); + final rows = await query.get(); + return rows.map((r) => r.read(remoteAssetId)!).toSet(); + } + + /// Remote asset ids held by live (non-dismissed) pending suggestions. + Future> getPendingSuggestionRemoteAssetIds() async { + final remoteAssetId = _db.pendingPhotoSuggestions.remoteAssetId; + final query = _db.selectOnly(_db.pendingPhotoSuggestions) + ..addColumns([remoteAssetId]) + ..where( + _db.pendingPhotoSuggestions.dismissed.equals(false) & + remoteAssetId.isNotNull(), + ); + final rows = await query.get(); + return rows.map((r) => r.read(remoteAssetId)!).toSet(); + } + + /// Inserts a pending suggestion; fills the id with a uuid when empty. + /// Per-device table: no sync record, no HLC. + Future createPendingSuggestion( + domain.PendingPhotoSuggestion suggestion, + ) async { + final id = suggestion.id.isEmpty ? _uuid.v4() : suggestion.id; + await _db + .into(_db.pendingPhotoSuggestions) + .insert( + PendingPhotoSuggestionsCompanion.insert( + id: id, + diveId: suggestion.diveId, + platformAssetId: suggestion.platformAssetId, + takenAt: suggestion.takenAt.millisecondsSinceEpoch, + thumbnailPath: Value(suggestion.thumbnailPath), + dismissed: Value(suggestion.dismissed), + createdAt: suggestion.createdAt.millisecondsSinceEpoch, + connectorAccountId: Value(suggestion.connectorAccountId), + remoteAssetId: Value(suggestion.remoteAssetId), + ), + ); + return domain.PendingPhotoSuggestion( + id: id, + diveId: suggestion.diveId, + platformAssetId: suggestion.platformAssetId, + takenAt: suggestion.takenAt, + thumbnailPath: suggestion.thumbnailPath, + dismissed: suggestion.dismissed, + createdAt: suggestion.createdAt, + connectorAccountId: suggestion.connectorAccountId, + remoteAssetId: suggestion.remoteAssetId, + ); + } + + /// Live (non-dismissed) suggestions for a dive, oldest capture first. + Future> getPendingSuggestionsForDive( + String diveId, + ) async { + final query = _db.select(_db.pendingPhotoSuggestions) + ..where((t) => t.diveId.equals(diveId) & t.dismissed.equals(false)) + ..orderBy([(t) => OrderingTerm.asc(t.takenAt)]); + final rows = await query.get(); + return rows.map(_mapRowToSuggestion).toList(); + } + + Future dismissPendingSuggestion(String id) async { + await (_db.update(_db.pendingPhotoSuggestions) + ..where((t) => t.id.equals(id))) + .write(const PendingPhotoSuggestionsCompanion(dismissed: Value(true))); + } + + /// Removes every candidate row for a confirmed connector asset (an + /// ambiguous match creates one suggestion per candidate dive). + Future deleteSuggestionsForRemoteAsset(String remoteAssetId) async { + await (_db.delete( + _db.pendingPhotoSuggestions, + )..where((t) => t.remoteAssetId.equals(remoteAssetId))).go(); + } + + domain.PendingPhotoSuggestion _mapRowToSuggestion( + PendingPhotoSuggestion row, + ) { + return domain.PendingPhotoSuggestion( + id: row.id, + diveId: row.diveId, + platformAssetId: row.platformAssetId, + takenAt: DateTime.fromMillisecondsSinceEpoch(row.takenAt, isUtc: true), + thumbnailPath: row.thumbnailPath, + dismissed: row.dismissed, + createdAt: DateTime.fromMillisecondsSinceEpoch( + row.createdAt, + isUtc: true, + ), + connectorAccountId: row.connectorAccountId, + remoteAssetId: row.remoteAssetId, + ); + } + /// Stamps the content identity computed by the upload pipeline. A synced /// row update: peers learn the hash even before upload confirmation. Future stampContentIdentity( diff --git a/lib/features/media/domain/entities/media_item.dart b/lib/features/media/domain/entities/media_item.dart index e7cff60bf..21b58ed98 100644 --- a/lib/features/media/domain/entities/media_item.dart +++ b/lib/features/media/domain/entities/media_item.dart @@ -432,7 +432,8 @@ class MediaSpeciesTag extends Equatable { ]; } -/// A pending suggestion to link a photo from the gallery to a dive +/// A pending suggestion to link a photo from the gallery or an external +/// connector (Lightroom) to a dive class PendingPhotoSuggestion extends Equatable { final String id; final String diveId; @@ -442,6 +443,11 @@ class PendingPhotoSuggestion extends Equatable { final bool dismissed; final DateTime createdAt; + /// Set on connector suggestions: the local ConnectorAccounts row and the + /// service-side asset id. Null on device-gallery suggestions. + final String? connectorAccountId; + final String? remoteAssetId; + const PendingPhotoSuggestion({ required this.id, required this.diveId, @@ -450,6 +456,8 @@ class PendingPhotoSuggestion extends Equatable { this.thumbnailPath, this.dismissed = false, required this.createdAt, + this.connectorAccountId, + this.remoteAssetId, }); @override @@ -461,6 +469,8 @@ class PendingPhotoSuggestion extends Equatable { thumbnailPath, dismissed, createdAt, + connectorAccountId, + remoteAssetId, ]; } diff --git a/test/core/database/migration_v105_profile_heading_test.dart b/test/core/database/migration_v105_profile_heading_test.dart index 0f008608c..c64ed54a7 100644 --- a/test/core/database/migration_v105_profile_heading_test.dart +++ b/test/core/database/migration_v105_profile_heading_test.dart @@ -114,9 +114,9 @@ void main() { }); test('version ladder includes 105', () { - // v105 is the newest migration: this test holds the exact-latest - // tripwire until the next migration lands and relaxes it. - expect(AppDatabase.currentSchemaVersion, 105); + // Exact-latest tripwire handed off to the newest migration's test + // (v106, Lightroom connector suggestions). + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(105)); expect(AppDatabase.migrationVersions, contains(105)); }); } diff --git a/test/core/database/migration_v106_connector_suggestions_test.dart b/test/core/database/migration_v106_connector_suggestions_test.dart new file mode 100644 index 000000000..eaa3393cb --- /dev/null +++ b/test/core/database/migration_v106_connector_suggestions_test.dart @@ -0,0 +1,106 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +void main() { + test('fresh v106 schema has connector columns on pending photo ' + 'suggestions', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('pending_photo_suggestions')") + .get(); + expect( + cols.map((c) => c.read('name')), + containsAll(['connector_account_id', 'remote_asset_id']), + ); + }); + + test('real onUpgrade from v105 adds the columns, preserving rows', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 105'); + // Minimal pre-v106 suggestions shape: enough columns for the row + // to survive and prove the ALTERs are additive. + rawDb.execute(''' + CREATE TABLE pending_photo_suggestions ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + platform_asset_id TEXT NOT NULL, + taken_at INTEGER NOT NULL, + thumbnail_path TEXT, + dismissed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ) + '''); + rawDb.execute( + "INSERT INTO pending_photo_suggestions " + "(id, dive_id, platform_asset_id, taken_at, created_at) " + "VALUES ('s1', 'd1', 'a1', 1, 1)", + ); + }, + ); + + final db = AppDatabase(nativeDb); + addTearDown(() => db.close()); + + final cols = await db + .customSelect("PRAGMA table_info('pending_photo_suggestions')") + .get(); + expect( + cols.map((c) => c.read('name')), + containsAll(['connector_account_id', 'remote_asset_id']), + ); + + final row = await db + .customSelect( + "SELECT platform_asset_id, remote_asset_id " + "FROM pending_photo_suggestions WHERE id = 's1'", + ) + .getSingle(); + expect(row.data['platform_asset_id'], 'a1'); + expect(row.data['remote_asset_id'], isNull); + }); + + test('version ladder includes 106', () { + // v106 is the newest migration: this test holds the exact-latest + // tripwire until the next migration lands and relaxes it. + expect(AppDatabase.currentSchemaVersion, 106); + expect(AppDatabase.migrationVersions, contains(106)); + }); + + test('backstop heals a database stranded past v106 by a parallel-branch ' + 'version collision', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute( + 'PRAGMA user_version = ${AppDatabase.currentSchemaVersion}', + ); + rawDb.execute(''' + CREATE TABLE pending_photo_suggestions ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL, + platform_asset_id TEXT NOT NULL, + taken_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + '''); + }, + ); + + final db = AppDatabase(nativeDb); + addTearDown(() => db.close()); + + // No migration runs (user_version == currentSchemaVersion); only the + // beforeOpen backstop can add the missing columns. + final cols = await db + .customSelect("PRAGMA table_info('pending_photo_suggestions')") + .get(); + expect( + cols.map((c) => c.read('name')), + containsAll(['connector_account_id', 'remote_asset_id']), + reason: 'connector columns must be re-asserted by beforeOpen', + ); + }); +} diff --git a/test/features/media/data/media_repository_suggestions_test.dart b/test/features/media/data/media_repository_suggestions_test.dart new file mode 100644 index 000000000..f186d06ad --- /dev/null +++ b/test/features/media/data/media_repository_suggestions_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart' + as domain; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; + +import '../../../helpers/test_database.dart'; + +void main() { + late MediaRepository repository; + late DiveRepository diveRepository; + + setUp(() async { + await setUpTestDatabase(); + repository = MediaRepository(); + diveRepository = DiveRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future createDive() => + diveRepository.createDive(Dive(id: '', dateTime: DateTime.utc(2026, 7))); + + domain.PendingPhotoSuggestion connectorSuggestion({ + required String diveId, + String assetId = 'lr-asset-1', + }) => domain.PendingPhotoSuggestion( + id: '', + diveId: diveId, + platformAssetId: assetId, + takenAt: DateTime.utc(2026, 7, 1, 10), + createdAt: DateTime.utc(2026, 7, 2), + connectorAccountId: 'acct1', + remoteAssetId: assetId, + ); + + test('createPendingSuggestion round-trips connector fields', () async { + final dive = await createDive(); + final created = await repository.createPendingSuggestion( + connectorSuggestion(diveId: dive.id), + ); + expect(created.id, isNotEmpty); + + final loaded = await repository.getPendingSuggestionsForDive(dive.id); + expect(loaded, hasLength(1)); + expect(loaded.single.remoteAssetId, 'lr-asset-1'); + expect(loaded.single.connectorAccountId, 'acct1'); + expect(loaded.single.platformAssetId, 'lr-asset-1'); + expect(loaded.single.takenAt, DateTime.utc(2026, 7, 1, 10)); + }); + + test('dismiss hides a suggestion from the list and the dedup set', () async { + final dive = await createDive(); + final created = await repository.createPendingSuggestion( + connectorSuggestion(diveId: dive.id), + ); + expect(await repository.getPendingSuggestionRemoteAssetIds(), { + 'lr-asset-1', + }); + + await repository.dismissPendingSuggestion(created.id); + expect(await repository.getPendingSuggestionsForDive(dive.id), isEmpty); + expect(await repository.getPendingSuggestionRemoteAssetIds(), isEmpty); + }); + + test('deleteSuggestionsForRemoteAsset removes all candidate rows', () async { + final diveA = await createDive(); + final diveB = await createDive(); + await repository.createPendingSuggestion( + connectorSuggestion(diveId: diveA.id), + ); + await repository.createPendingSuggestion( + connectorSuggestion(diveId: diveB.id), + ); + + await repository.deleteSuggestionsForRemoteAsset('lr-asset-1'); + expect(await repository.getPendingSuggestionsForDive(diveA.id), isEmpty); + expect(await repository.getPendingSuggestionsForDive(diveB.id), isEmpty); + }); + + test( + 'getConnectorRemoteAssetIds returns only serviceConnector media rows', + () async { + final dive = await createDive(); + final now = DateTime.utc(2026, 7, 1); + await repository.createMedia( + domain.MediaItem( + id: '', + diveId: dive.id, + mediaType: domain.MediaType.photo, + takenAt: now, + createdAt: now, + updatedAt: now, + sourceType: MediaSourceType.serviceConnector, + connectorAccountId: 'acct1', + remoteAssetId: 'lr-linked-1', + ), + ); + await repository.createMedia( + domain.MediaItem( + id: '', + diveId: dive.id, + mediaType: domain.MediaType.photo, + takenAt: now, + createdAt: now, + updatedAt: now, + sourceType: MediaSourceType.platformGallery, + platformAssetId: 'gallery-1', + ), + ); + + expect(await repository.getConnectorRemoteAssetIds(), {'lr-linked-1'}); + }, + ); +} From cd7f0f28981f83976b5b559346eda03bac26ca3d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:39:33 -0400 Subject: [PATCH 10/22] feat(media): confidence-bearing timestamp matching on DivePhotoMatcher --- .../domain/services/dive_photo_matcher.dart | 72 +++++++++++++++++++ .../dive_photo_matcher_timestamp_test.dart | 71 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 test/features/media/domain/dive_photo_matcher_timestamp_test.dart diff --git a/lib/features/media/domain/services/dive_photo_matcher.dart b/lib/features/media/domain/services/dive_photo_matcher.dart index 359693aa3..5d4f90c95 100644 --- a/lib/features/media/domain/services/dive_photo_matcher.dart +++ b/lib/features/media/domain/services/dive_photo_matcher.dart @@ -31,6 +31,8 @@ class DiveBounds { /// /// Files with no [takenAt] or no matching dive go to [MatchedSelection.unmatched]. class DivePhotoMatcher { + const DivePhotoMatcher(); + /// Pre-dive buffer applied before [DiveBounds.entryTime] when computing /// the match window. Catches photos taken at the boat / dock / on the /// surface before the descent. @@ -79,4 +81,74 @@ class DivePhotoMatcher { return MatchedSelection(matched: matched, unmatched: unmatched); } + + /// Confidence-bearing match of a single timestamp against dive windows + /// (Lightroom auto-linking; adoptable by the gallery scanner later). + /// + /// Extended window = `[entry - preBuffer, exit + postBuffer]`, core + /// window = `[entry, exit]`, boundaries inclusive. + /// - No extended hit: [TimestampMatchKind.none]. + /// - Exactly one extended hit: confident. + /// - Several extended hits with exactly one core hit: confident for the + /// core dive (a photo taken during dive B also lands in dive A's + /// post-margin; the core hit is unambiguous). + /// - Otherwise ambiguous, candidates ordered by |takenAt - entry|. + TimestampMatch matchTimestamp({ + required DateTime takenAt, + required List dives, + }) { + bool inExtended(DiveBounds d) => + !takenAt.isBefore(d.entryTime.subtract(preBuffer)) && + !takenAt.isAfter(d.exitTime.add(postBuffer)); + bool inCore(DiveBounds d) => + !takenAt.isBefore(d.entryTime) && !takenAt.isAfter(d.exitTime); + + final extended = dives.where(inExtended).toList(); + if (extended.isEmpty) { + return const TimestampMatch(kind: TimestampMatchKind.none); + } + if (extended.length == 1) { + return TimestampMatch( + kind: TimestampMatchKind.confident, + diveId: extended.single.diveId, + ); + } + final core = extended.where(inCore).toList(); + if (core.length == 1) { + return TimestampMatch( + kind: TimestampMatchKind.confident, + diveId: core.single.diveId, + ); + } + extended.sort( + (a, b) => takenAt + .difference(a.entryTime) + .abs() + .compareTo(takenAt.difference(b.entryTime).abs()), + ); + return TimestampMatch( + kind: TimestampMatchKind.ambiguous, + candidateDiveIds: [for (final d in extended) d.diveId], + ); + } +} + +/// Outcome kinds for [DivePhotoMatcher.matchTimestamp]. +enum TimestampMatchKind { confident, ambiguous, none } + +/// Result of matching one timestamp against dive windows. +class TimestampMatch { + const TimestampMatch({ + required this.kind, + this.diveId, + this.candidateDiveIds = const [], + }); + + final TimestampMatchKind kind; + + /// The matched dive when [kind] is confident. + final String? diveId; + + /// Candidate dives when [kind] is ambiguous, closest entry first. + final List candidateDiveIds; } diff --git a/test/features/media/domain/dive_photo_matcher_timestamp_test.dart b/test/features/media/domain/dive_photo_matcher_timestamp_test.dart new file mode 100644 index 000000000..890eff6ac --- /dev/null +++ b/test/features/media/domain/dive_photo_matcher_timestamp_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/domain/services/dive_photo_matcher.dart'; + +void main() { + const matcher = DivePhotoMatcher(); + + // Dive A: 10:00 - 11:00. Extended window 09:30 - 12:00. + final diveA = DiveBounds( + diveId: 'A', + entryTime: DateTime.utc(2026, 7, 1, 10), + exitTime: DateTime.utc(2026, 7, 1, 11), + ); + // Dive B: 12:30 - 13:30. Extended window 12:00 - 14:30. + final diveB = DiveBounds( + diveId: 'B', + entryTime: DateTime.utc(2026, 7, 1, 12, 30), + exitTime: DateTime.utc(2026, 7, 1, 13, 30), + ); + + TimestampMatch match(DateTime t, List dives) => + matcher.matchTimestamp(takenAt: t, dives: dives); + + test('inside core window of a lone dive is confident', () { + final result = match(DateTime.utc(2026, 7, 1, 10, 30), [diveA]); + expect(result.kind, TimestampMatchKind.confident); + expect(result.diveId, 'A'); + }); + + test('post-margin of a lone dive is confident (single extended hit)', () { + final result = match(DateTime.utc(2026, 7, 1, 11, 45), [diveA]); + expect(result.kind, TimestampMatchKind.confident); + expect(result.diveId, 'A'); + }); + + test('surface interval covered by two extended windows is ambiguous with ' + 'candidates ordered by entry proximity', () { + // 12:00 sits in A's post-margin (exit 11:00 + 60m) boundary and B's + // pre-margin (entry 12:30 - 30m) boundary. |12:00-12:30| = 30m beats + // |12:00-10:00| = 2h, so B is the closer candidate. + final result = match(DateTime.utc(2026, 7, 1, 12), [diveA, diveB]); + expect(result.kind, TimestampMatchKind.ambiguous); + expect(result.candidateDiveIds, ['B', 'A']); + expect(result.diveId, isNull); + }); + + test('a unique core hit wins over another dive margin overlap', () { + // Dive C overlaps A's post-margin with its core: C runs 11:30 - 12:30. + final diveC = DiveBounds( + diveId: 'C', + entryTime: DateTime.utc(2026, 7, 1, 11, 30), + exitTime: DateTime.utc(2026, 7, 1, 12, 30), + ); + // 11:45 is inside C's core and inside A's extended window. + final result = match(DateTime.utc(2026, 7, 1, 11, 45), [diveA, diveC]); + expect(result.kind, TimestampMatchKind.confident); + expect(result.diveId, 'C'); + }); + + test('outside every window is none', () { + final result = match(DateTime.utc(2026, 7, 1, 8), [diveA, diveB]); + expect(result.kind, TimestampMatchKind.none); + }); + + test('extended window boundaries are inclusive', () { + final atStart = match(DateTime.utc(2026, 7, 1, 9, 30), [diveA]); + expect(atStart.kind, TimestampMatchKind.confident); + + final beforeStart = match(DateTime.utc(2026, 7, 1, 9, 29, 59), [diveA]); + expect(beforeStart.kind, TimestampMatchKind.none); + }); +} From 4658a97ac58525946c20769bf9d5fa698425e69b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:40:46 -0400 Subject: [PATCH 11/22] style(media): const DivePhotoMatcher call sites --- lib/features/media/data/services/trip_media_scanner.dart | 2 +- lib/features/media/presentation/widgets/files_tab.dart | 5 ++++- .../media/domain/services/dive_photo_matcher_test.dart | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/features/media/data/services/trip_media_scanner.dart b/lib/features/media/data/services/trip_media_scanner.dart index 4de2ebdd2..1936835ab 100644 --- a/lib/features/media/data/services/trip_media_scanner.dart +++ b/lib/features/media/data/services/trip_media_scanner.dart @@ -255,7 +255,7 @@ class TripMediaScanner { } } - final matcher = DivePhotoMatcher(); + const matcher = DivePhotoMatcher(); final primarySelection = matcher.match( files: primaryExtractedById.values.toList(), dives: bounds, diff --git a/lib/features/media/presentation/widgets/files_tab.dart b/lib/features/media/presentation/widgets/files_tab.dart index ad2aec153..b3dc8a526 100644 --- a/lib/features/media/presentation/widgets/files_tab.dart +++ b/lib/features/media/presentation/widgets/files_tab.dart @@ -142,7 +142,10 @@ class FilesTab extends ConsumerWidget { ), ) .toList(); - final result = DivePhotoMatcher().match(files: extracted, dives: bounds); + final result = const DivePhotoMatcher().match( + files: extracted, + dives: bounds, + ); notifier.setFiles(extracted, match: result); } // coverage:ignore-end diff --git a/test/features/media/domain/services/dive_photo_matcher_test.dart b/test/features/media/domain/services/dive_photo_matcher_test.dart index 516f22057..5e059b43a 100644 --- a/test/features/media/domain/services/dive_photo_matcher_test.dart +++ b/test/features/media/domain/services/dive_photo_matcher_test.dart @@ -15,7 +15,7 @@ DiveBounds _dive(String id, DateTime start, Duration runtime) => DiveBounds(diveId: id, entryTime: start, exitTime: start.add(runtime)); void main() { - final matcher = DivePhotoMatcher(); + const matcher = DivePhotoMatcher(); test('routes file taken during dive window to that dive', () { final dive = _dive( From 64a14d1a27e821bf909316d575509099a11848d2 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:41:34 -0400 Subject: [PATCH 12/22] feat(lightroom): per-device connector scan state in SharedPreferences --- .../services/lightroom_connector_state.dart | 64 +++++++++++++++++++ .../data/lightroom_connector_state_test.dart | 62 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 lib/features/media/data/services/lightroom_connector_state.dart create mode 100644 test/features/media/data/lightroom_connector_state_test.dart diff --git a/lib/features/media/data/services/lightroom_connector_state.dart b/lib/features/media/data/services/lightroom_connector_state.dart new file mode 100644 index 000000000..dd9749569 --- /dev/null +++ b/lib/features/media/data/services/lightroom_connector_state.dart @@ -0,0 +1,64 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Per-device Lightroom connector scan state, keyed by connector account +/// id. Lives in SharedPreferences (not the synced database) on purpose: +/// each connected device polls independently, and a device that never +/// connected has no state at all. Mirrors the media store's attach-state +/// precedent. +class LightroomConnectorState { + LightroomConnectorState({ + required SharedPreferences prefs, + required String accountId, + }) : _prefs = prefs, + _accountId = accountId; + + final SharedPreferences _prefs; + final String _accountId; + + String get _lastPollKey => 'lightroom_${_accountId}_last_poll_at'; + String get _albumIdsKey => 'lightroom_${_accountId}_album_ids'; + String get _autoPollKey => 'lightroom_${_accountId}_auto_poll'; + String get _lastErrorKey => 'lightroom_${_accountId}_last_error'; + + Future lastPollAt() async { + final ms = _prefs.getInt(_lastPollKey); + return ms == null + ? null + : DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); + } + + Future setLastPollAt(DateTime t) => + _prefs.setInt(_lastPollKey, t.millisecondsSinceEpoch); + + /// Album ids to scan; empty means the whole catalog. + Future> albumIds() async => + _prefs.getStringList(_albumIdsKey) ?? const []; + + Future setAlbumIds(List ids) => + _prefs.setStringList(_albumIdsKey, ids); + + Future autoPollEnabled() async => _prefs.getBool(_autoPollKey) ?? true; + + Future setAutoPollEnabled(bool enabled) => + _prefs.setBool(_autoPollKey, enabled); + + /// The last scan/poll failure, surfaced as a needs-attention state in + /// settings. Null when the last run succeeded. + Future lastError() async => _prefs.getString(_lastErrorKey); + + Future setLastError(String? message) async { + if (message == null) { + await _prefs.remove(_lastErrorKey); + } else { + await _prefs.setString(_lastErrorKey, message); + } + } + + /// Removes every stored field (disconnect). + Future clear() async { + await _prefs.remove(_lastPollKey); + await _prefs.remove(_albumIdsKey); + await _prefs.remove(_autoPollKey); + await _prefs.remove(_lastErrorKey); + } +} diff --git a/test/features/media/data/lightroom_connector_state_test.dart b/test/features/media/data/lightroom_connector_state_test.dart new file mode 100644 index 000000000..1c2ea03a8 --- /dev/null +++ b/test/features/media/data/lightroom_connector_state_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; + +void main() { + late LightroomConnectorState state; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + state = LightroomConnectorState(prefs: prefs, accountId: 'acct1'); + }); + + test( + 'defaults: no poll time, empty albums, auto-poll on, no error', + () async { + expect(await state.lastPollAt(), isNull); + expect(await state.albumIds(), isEmpty); + expect(await state.autoPollEnabled(), isTrue); + expect(await state.lastError(), isNull); + }, + ); + + test('round-trips each field', () async { + final t = DateTime.utc(2026, 7, 11, 8, 30); + await state.setLastPollAt(t); + await state.setAlbumIds(['al1', 'al2']); + await state.setAutoPollEnabled(false); + await state.setLastError('boom'); + + expect(await state.lastPollAt(), t); + expect(await state.albumIds(), ['al1', 'al2']); + expect(await state.autoPollEnabled(), isFalse); + expect(await state.lastError(), 'boom'); + }); + + test('setLastError(null) clears the error', () async { + await state.setLastError('boom'); + await state.setLastError(null); + expect(await state.lastError(), isNull); + }); + + test('clear resets all fields', () async { + await state.setLastPollAt(DateTime.utc(2026)); + await state.setAlbumIds(['al1']); + await state.setAutoPollEnabled(false); + await state.setLastError('boom'); + + await state.clear(); + expect(await state.lastPollAt(), isNull); + expect(await state.albumIds(), isEmpty); + expect(await state.autoPollEnabled(), isTrue); + expect(await state.lastError(), isNull); + }); + + test('different account ids do not collide', () async { + final prefs = await SharedPreferences.getInstance(); + final other = LightroomConnectorState(prefs: prefs, accountId: 'acct2'); + await state.setAlbumIds(['al1']); + expect(await other.albumIds(), isEmpty); + }); +} From 1c3f7a6be655106017bc71e836393b42c41b0500 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:46:13 -0400 Subject: [PATCH 13/22] feat(lightroom): scan service - window merge, dedup, confident attach, suggestions, poll --- .../data/services/lightroom_scan_service.dart | 315 ++++++++++++++ .../data/lightroom_scan_service_test.dart | 403 ++++++++++++++++++ 2 files changed, 718 insertions(+) create mode 100644 lib/features/media/data/services/lightroom_scan_service.dart create mode 100644 test/features/media/data/lightroom_scan_service_test.dart diff --git a/lib/features/media/data/services/lightroom_scan_service.dart b/lib/features/media/data/services/lightroom_scan_service.dart new file mode 100644 index 000000000..fc16e9cec --- /dev/null +++ b/lib/features/media/data/services/lightroom_scan_service.dart @@ -0,0 +1,315 @@ +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/lightroom/lightroom_models.dart'; +import 'package:submersion/core/services/logger_service.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/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/enrichment_service.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart' + as domain; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/services/dive_photo_matcher.dart'; + +/// Counters describing one scan run, for the summary snackbar and the +/// settings status line. +class LightroomScanSummary { + int examined = 0; + int attached = 0; + int suggested = 0; + int skippedExisting = 0; + int skippedNoCaptureTime = 0; +} + +/// Matches Lightroom catalog assets to dives by capture time. +/// +/// Confident matches become `serviceConnector` media rows (enriched from +/// the dive profile, then enqueued into the media store transfer queue); +/// ambiguous matches become pending suggestions, one row per candidate +/// dive. Dedup happens BEFORE row creation against remote asset ids on +/// synced media rows, which is what makes re-scans and second-device +/// scans idempotent. +class LightroomScanService { + LightroomScanService({ + required LightroomApiClient api, + required MediaRepository mediaRepository, + required DiveRepository diveRepository, + required void Function(String mediaId) enqueueUpload, + EnrichmentService enrichmentService = const EnrichmentService(), + DivePhotoMatcher matcher = const DivePhotoMatcher(), + DateTime Function()? now, + }) : _api = api, + _mediaRepository = mediaRepository, + _diveRepository = diveRepository, + _enqueueUpload = enqueueUpload, + _enrichmentService = enrichmentService, + _matcher = matcher, + _now = now ?? DateTime.now; + + /// How far back [poll] looks for dives whose windows may have gained + /// new Lightroom uploads. Old photos uploaded later than this are + /// caught by a manual scan. + static const Duration pollLookback = Duration(days: 90); + + final LightroomApiClient _api; + final MediaRepository _mediaRepository; + final DiveRepository _diveRepository; + final void Function(String mediaId) _enqueueUpload; + final EnrichmentService _enrichmentService; + final DivePhotoMatcher _matcher; + final DateTime Function() _now; + final _log = LoggerService.forClass(LightroomScanService); + + /// Merged capture-time query spans from dive windows (entry - preBuffer + /// to exit + postBuffer), so a day of repetitive dives is one API query + /// instead of one per dive. + static List<({DateTime start, DateTime end})> mergeWindows( + List bounds, + ) { + if (bounds.isEmpty) return const []; + final windows = + bounds + .map( + (b) => ( + start: b.entryTime.subtract(DivePhotoMatcher.preBuffer), + end: b.exitTime.add(DivePhotoMatcher.postBuffer), + ), + ) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + final merged = [windows.first]; + for (final w in windows.skip(1)) { + final last = merged.last; + if (!w.start.isAfter(last.end)) { + if (w.end.isAfter(last.end)) { + merged[merged.length - 1] = (start: last.start, end: w.end); + } + } else { + merged.add(w); + } + } + return merged; + } + + /// Scans the catalog for assets matching [dives] and attaches or + /// suggests them. Assets already linked or suggested (on any device; + /// media rows sync) are skipped. + Future scanDives({ + required ConnectorAccount account, + required List dives, + required LightroomConnectorState state, + }) async { + final summary = LightroomScanSummary(); + final catalogId = account.accountIdentifier; + if (catalogId == null || dives.isEmpty) return summary; + + final bounds = [for (final dive in dives) _boundsFor(dive)]; + final spans = mergeWindows(bounds); + + final existing = await _mediaRepository.getConnectorRemoteAssetIds(); + final suggested = await _mediaRepository + .getPendingSuggestionRemoteAssetIds(); + final albumIds = await state.albumIds(); + + final assets = albumIds.isEmpty + ? await _fetchCatalogAssets(catalogId, spans) + : await _fetchAlbumAssets(catalogId, albumIds); + + final seenThisScan = {}; + for (final asset in assets) { + if (!seenThisScan.add(asset.id)) continue; + summary.examined++; + if (asset.captureDate == null) { + summary.skippedNoCaptureTime++; + continue; + } + if (existing.contains(asset.id) || suggested.contains(asset.id)) { + summary.skippedExisting++; + continue; + } + final match = _matcher.matchTimestamp( + takenAt: asset.captureDate!, + dives: bounds, + ); + switch (match.kind) { + case TimestampMatchKind.none: + break; + case TimestampMatchKind.confident: + await _attach(asset, diveId: match.diveId!, account: account); + summary.attached++; + case TimestampMatchKind.ambiguous: + for (final diveId in match.candidateDiveIds) { + await _mediaRepository.createPendingSuggestion( + domain.PendingPhotoSuggestion( + id: '', + diveId: diveId, + platformAssetId: asset.id, + takenAt: asset.captureDate!, + createdAt: _now(), + connectorAccountId: account.id, + remoteAssetId: asset.id, + ), + ); + } + summary.suggested++; + } + } + _log.info( + 'Lightroom scan: ${summary.examined} examined, ' + '${summary.attached} attached, ${summary.suggested} suggested, ' + '${summary.skippedExisting} already linked', + ); + return summary; + } + + /// Scans dives from the last [pollLookback] and stamps the poll time. + /// Runs the same pipeline as a manual scan, so a failure mid-way leaves + /// no partial state that a re-run would not reconcile (dedup). + Future poll({ + required ConnectorAccount account, + required LightroomConnectorState state, + }) async { + final until = _now(); + final dives = await _diveRepository.getDivesInRange( + until.subtract(pollLookback), + until, + ); + final summary = await scanDives( + account: account, + dives: dives, + state: state, + ); + await state.setLastPollAt(until); + return summary; + } + + /// Confirms an ambiguous suggestion: creates the media row for the + /// suggestion's dive and removes every candidate row for the asset. + /// Filename/GPS/duration are not stored on suggestions; the row carries + /// the identity fields, and the store pipeline supplies display bytes. + Future confirmSuggestion({ + required ConnectorAccount account, + required domain.PendingPhotoSuggestion suggestion, + }) async { + final remoteAssetId = suggestion.remoteAssetId; + if (remoteAssetId == null) return; + await _attach( + LightroomAsset( + id: remoteAssetId, + subtype: 'image', + captureDate: suggestion.takenAt, + ), + diveId: suggestion.diveId, + account: account, + ); + await _mediaRepository.deleteSuggestionsForRemoteAsset(remoteAssetId); + } + + Future> _fetchCatalogAssets( + String catalogId, + List<({DateTime start, DateTime end})> spans, + ) async { + final assets = []; + for (final span in spans) { + String? next; + do { + final page = await _api.listAssets( + catalogId, + capturedAfter: span.start, + capturedBefore: span.end, + nextUrl: next, + ); + assets.addAll(page.assets); + next = page.nextUrl; + } while (next != null); + } + return assets; + } + + Future> _fetchAlbumAssets( + String catalogId, + List albumIds, + ) async { + final assets = []; + for (final albumId in albumIds) { + String? next; + do { + final page = await _api.listAlbumAssets( + catalogId, + albumId, + nextUrl: next, + ); + assets.addAll(page.assets); + next = page.nextUrl; + } while (next != null); + } + return assets; + } + + Future _attach( + LightroomAsset asset, { + required String diveId, + required ConnectorAccount account, + }) async { + final now = _now(); + final saved = await _mediaRepository.createMedia( + domain.MediaItem( + id: '', + diveId: diveId, + mediaType: asset.isVideo + ? domain.MediaType.video + : domain.MediaType.photo, + takenAt: asset.captureDate!, + originalFilename: asset.fileName, + latitude: asset.latitude, + longitude: asset.longitude, + durationSeconds: asset.videoDurationSeconds, + sourceType: MediaSourceType.serviceConnector, + connectorAccountId: account.id, + remoteAssetId: asset.id, + createdAt: now, + updatedAt: now, + ), + ); + + final dive = await _diveRepository.getDiveById(diveId); + final profile = dive?.profile ?? const []; + if (dive != null && profile.isNotEmpty) { + final result = _enrichmentService.calculateEnrichment( + profile: profile, + diveStartTime: dive.effectiveEntryTime, + photoTime: asset.captureDate!, + ); + if (result.depthMeters != null || + result.matchConfidence != domain.MatchConfidence.noProfile) { + await _mediaRepository.saveEnrichment( + domain.MediaEnrichment( + id: '', + mediaId: saved.id, + diveId: dive.id, + depthMeters: result.depthMeters, + temperatureCelsius: result.temperatureCelsius, + elapsedSeconds: result.elapsedSeconds, + matchConfidence: result.matchConfidence, + timestampOffsetSeconds: result.timestampOffsetSeconds, + createdAt: now, + ), + ); + } + } + _enqueueUpload(saved.id); + } + + /// Dive time bounds with the gallery scanner's fallbacks: entry falls + /// back to the dive's dateTime; exit to entry + runtime, or one hour. + DiveBounds _boundsFor(Dive dive) { + final entry = dive.entryTime ?? dive.dateTime; + final exit = + dive.exitTime ?? + (dive.effectiveRuntime != null + ? entry.add(dive.effectiveRuntime!) + : entry.add(const Duration(minutes: 60))); + return DiveBounds(diveId: dive.id, entryTime: entry, exitTime: exit); + } +} diff --git a/test/features/media/data/lightroom_scan_service_test.dart b/test/features/media/data/lightroom_scan_service_test.dart new file mode 100644 index 000000000..44f1bf02e --- /dev/null +++ b/test/features/media/data/lightroom_scan_service_test.dart @@ -0,0 +1,403 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/database/database.dart' + show DiveProfilesCompanion; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/lightroom/lightroom_models.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/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; +import 'package:submersion/features/media/data/services/lightroom_scan_service.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart' + as domain; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/services/dive_photo_matcher.dart'; + +import '../../../helpers/test_database.dart'; + +class _FakeLightroomApi extends LightroomApiClient { + _FakeLightroomApi({required this.assets, this.albumAssets = const {}}) + : super(auth: AdobeImsAuthManager()); + + final List assets; + final Map> albumAssets; + final List<({DateTime? after, DateTime? before})> assetCalls = []; + final List albumCalls = []; + + @override + Future listAssets( + String catalogId, { + DateTime? capturedAfter, + DateTime? capturedBefore, + String? nextUrl, + }) async { + assetCalls.add((after: capturedAfter, before: capturedBefore)); + final inSpan = assets + .where( + (a) => + a.captureDate == null || + ((capturedAfter == null || + !a.captureDate!.isBefore(capturedAfter)) && + (capturedBefore == null || + !a.captureDate!.isAfter(capturedBefore))), + ) + .toList(); + return LightroomAssetPage(assets: inSpan); + } + + @override + Future listAlbumAssets( + String catalogId, + String albumId, { + String? nextUrl, + }) async { + albumCalls.add(albumId); + return LightroomAssetPage(assets: albumAssets[albumId] ?? const []); + } +} + +void main() { + late MediaRepository mediaRepository; + late DiveRepository diveRepository; + late LightroomConnectorState state; + late List enqueued; + + final account = ConnectorAccount( + id: 'acct1', + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + addedAt: DateTime.utc(2026, 7, 1), + ); + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + mediaRepository = MediaRepository(); + diveRepository = DiveRepository(); + state = LightroomConnectorState(prefs: prefs, accountId: account.id); + enqueued = []; + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + LightroomScanService service(_FakeLightroomApi api) => LightroomScanService( + api: api, + mediaRepository: mediaRepository, + diveRepository: diveRepository, + enqueueUpload: enqueued.add, + now: () => DateTime.utc(2026, 7, 11, 12), + ); + + Future createDive({required DateTime entry, required DateTime exit}) => + diveRepository.createDive( + Dive(id: '', dateTime: entry, entryTime: entry, exitTime: exit), + ); + + LightroomAsset image(String id, DateTime? captured) => LightroomAsset( + id: id, + subtype: 'image', + captureDate: captured, + fileName: '$id.jpg', + ); + + test('mergeWindows merges overlapping windows and keeps disjoint spans', () { + final bounds = [ + DiveBounds( + diveId: 'A', + entryTime: DateTime.utc(2026, 7, 1, 10), + exitTime: DateTime.utc(2026, 7, 1, 11), + ), + // B's pre-buffer (12:30 - 30m = 12:00) touches A's post-buffer + // (11:00 + 60m = 12:00): one merged span. + DiveBounds( + diveId: 'B', + entryTime: DateTime.utc(2026, 7, 1, 12, 30), + exitTime: DateTime.utc(2026, 7, 1, 13, 30), + ), + DiveBounds( + diveId: 'C', + entryTime: DateTime.utc(2026, 7, 3, 10), + exitTime: DateTime.utc(2026, 7, 3, 11), + ), + ]; + final spans = LightroomScanService.mergeWindows(bounds); + expect(spans, hasLength(2)); + expect(spans[0].start, DateTime.utc(2026, 7, 1, 9, 30)); + expect(spans[0].end, DateTime.utc(2026, 7, 1, 14, 30)); + expect(spans[1].start, DateTime.utc(2026, 7, 3, 9, 30)); + expect(spans[1].end, DateTime.utc(2026, 7, 3, 12)); + }); + + test('confident match attaches a connector media row with enrichment and ' + 'enqueues an upload', () async { + final dive = await createDive( + entry: DateTime.utc(2026, 7, 1, 10), + exit: DateTime.utc(2026, 7, 1, 11), + ); + final db = DatabaseService.instance.database; + for (final (ts, depth) in [(0, 0.0), (1800, 18.0), (3600, 0.0)]) { + await db + .into(db.diveProfiles) + .insert( + DiveProfilesCompanion( + id: Value('p$ts'), + diveId: Value(dive.id), + isPrimary: const Value(true), + timestamp: Value(ts), + depth: Value(depth), + ), + ); + } + + final asset = LightroomAsset( + id: 'lr1', + subtype: 'image', + captureDate: DateTime.utc(2026, 7, 1, 10, 30), + fileName: 'reef.jpg', + latitude: 4.5, + longitude: 55.5, + ); + final api = _FakeLightroomApi(assets: [asset]); + final summary = await service( + api, + ).scanDives(account: account, dives: [dive], state: state); + + expect(summary.attached, 1); + expect(summary.examined, 1); + + final media = await mediaRepository.getMediaForDive(dive.id); + expect(media, hasLength(1)); + final item = media.single; + expect(item.sourceType, MediaSourceType.serviceConnector); + expect(item.remoteAssetId, 'lr1'); + expect(item.connectorAccountId, 'acct1'); + expect(item.originalFilename, 'reef.jpg'); + expect(item.latitude, 4.5); + // The repository maps takenAt back through the local zone; the instant + // is what must survive. + expect( + item.takenAt.millisecondsSinceEpoch, + DateTime.utc(2026, 7, 1, 10, 30).millisecondsSinceEpoch, + ); + expect(item.enrichment, isNotNull); + expect(item.enrichment!.depthMeters, 18.0); + expect(enqueued, [item.id]); + }); + + test('video asset attaches with duration and enqueues', () async { + final dive = await createDive( + entry: DateTime.utc(2026, 7, 1, 10), + exit: DateTime.utc(2026, 7, 1, 11), + ); + final api = _FakeLightroomApi( + assets: [ + LightroomAsset( + id: 'vid1', + subtype: 'video', + captureDate: DateTime.utc(2026, 7, 1, 10, 15), + fileName: 'clip.mp4', + videoDurationSeconds: 42, + ), + ], + ); + final summary = await service( + api, + ).scanDives(account: account, dives: [dive], state: state); + + expect(summary.attached, 1); + final item = (await mediaRepository.getMediaForDive(dive.id)).single; + expect(item.mediaType, domain.MediaType.video); + expect(item.durationSeconds, 42); + expect(enqueued, [item.id]); + }); + + test('ambiguous match creates one suggestion per candidate dive and no ' + 'media row', () async { + final diveA = await createDive( + entry: DateTime.utc(2026, 7, 1, 10), + exit: DateTime.utc(2026, 7, 1, 11), + ); + final diveB = await createDive( + entry: DateTime.utc(2026, 7, 1, 12, 30), + exit: DateTime.utc(2026, 7, 1, 13, 30), + ); + final api = _FakeLightroomApi( + assets: [image('surface1', DateTime.utc(2026, 7, 1, 12))], + ); + final summary = await service( + api, + ).scanDives(account: account, dives: [diveA, diveB], state: state); + + expect(summary.attached, 0); + expect(summary.suggested, 1); + expect(await mediaRepository.getMediaForDive(diveA.id), isEmpty); + expect(await mediaRepository.getMediaForDive(diveB.id), isEmpty); + final forA = await mediaRepository.getPendingSuggestionsForDive(diveA.id); + final forB = await mediaRepository.getPendingSuggestionsForDive(diveB.id); + expect(forA, hasLength(1)); + expect(forB, hasLength(1)); + expect(forA.single.remoteAssetId, 'surface1'); + expect(forA.single.connectorAccountId, 'acct1'); + expect(enqueued, isEmpty); + }); + + test('already linked and already suggested assets are skipped', () async { + final dive = await createDive( + entry: DateTime.utc(2026, 7, 1, 10), + exit: DateTime.utc(2026, 7, 1, 11), + ); + await mediaRepository.createMedia( + domain.MediaItem( + id: '', + diveId: dive.id, + mediaType: domain.MediaType.photo, + takenAt: DateTime.utc(2026, 7, 1, 10, 30), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + sourceType: MediaSourceType.serviceConnector, + connectorAccountId: 'other-device-acct', + remoteAssetId: 'linked1', + ), + ); + await mediaRepository.createPendingSuggestion( + domain.PendingPhotoSuggestion( + id: '', + diveId: dive.id, + platformAssetId: 'suggested1', + takenAt: DateTime.utc(2026, 7, 1, 10, 40), + createdAt: DateTime.utc(2026, 7, 1), + connectorAccountId: 'acct1', + remoteAssetId: 'suggested1', + ), + ); + + final api = _FakeLightroomApi( + assets: [ + image('linked1', DateTime.utc(2026, 7, 1, 10, 30)), + image('suggested1', DateTime.utc(2026, 7, 1, 10, 40)), + ], + ); + final summary = await service( + api, + ).scanDives(account: account, dives: [dive], state: state); + + expect(summary.skippedExisting, 2); + expect(summary.attached, 0); + expect((await mediaRepository.getMediaForDive(dive.id)), hasLength(1)); + expect(enqueued, isEmpty); + }); + + test('asset without capture date is counted and skipped', () async { + final dive = await createDive( + entry: DateTime.utc(2026, 7, 1, 10), + exit: DateTime.utc(2026, 7, 1, 11), + ); + final api = _FakeLightroomApi(assets: [image('nodate', null)]); + final summary = await service( + api, + ).scanDives(account: account, dives: [dive], state: state); + + expect(summary.skippedNoCaptureTime, 1); + expect(summary.attached, 0); + }); + + test( + 'album filter routes through listAlbumAssets and still window-filters', + () async { + final dive = await createDive( + entry: DateTime.utc(2026, 7, 1, 10), + exit: DateTime.utc(2026, 7, 1, 11), + ); + await state.setAlbumIds(['al1']); + final api = _FakeLightroomApi( + assets: [image('catalogOnly', DateTime.utc(2026, 7, 1, 10, 30))], + albumAssets: { + 'al1': [ + image('inAlbum', DateTime.utc(2026, 7, 1, 10, 30)), + image('outOfWindow', DateTime.utc(2026, 6, 1)), + ], + }, + ); + final summary = await service( + api, + ).scanDives(account: account, dives: [dive], state: state); + + expect(api.albumCalls, ['al1']); + expect(api.assetCalls, isEmpty); + expect(summary.attached, 1); + final item = (await mediaRepository.getMediaForDive(dive.id)).single; + expect(item.remoteAssetId, 'inAlbum'); + }, + ); + + test( + 'confirmSuggestion attaches the dive and clears all candidate rows', + () async { + final diveA = await createDive( + entry: DateTime.utc(2026, 7, 1, 10), + exit: DateTime.utc(2026, 7, 1, 11), + ); + final diveB = await createDive( + entry: DateTime.utc(2026, 7, 1, 12, 30), + exit: DateTime.utc(2026, 7, 1, 13, 30), + ); + final api = _FakeLightroomApi( + assets: [image('surface1', DateTime.utc(2026, 7, 1, 12))], + ); + final svc = service(api); + await svc.scanDives( + account: account, + dives: [diveA, diveB], + state: state, + ); + final suggestion = (await mediaRepository.getPendingSuggestionsForDive( + diveB.id, + )).single; + + await svc.confirmSuggestion(account: account, suggestion: suggestion); + + final item = (await mediaRepository.getMediaForDive(diveB.id)).single; + expect(item.remoteAssetId, 'surface1'); + expect(item.sourceType, MediaSourceType.serviceConnector); + expect(enqueued, [item.id]); + expect( + await mediaRepository.getPendingSuggestionsForDive(diveA.id), + isEmpty, + ); + expect( + await mediaRepository.getPendingSuggestionsForDive(diveB.id), + isEmpty, + ); + }, + ); + + test( + 'poll scans dives within the lookback window and stamps lastPollAt', + () async { + // now is fixed at 2026-07-11; a dive 5 days earlier is inside the + // 90-day lookback. + final dive = await createDive( + entry: DateTime.utc(2026, 7, 6, 10), + exit: DateTime.utc(2026, 7, 6, 11), + ); + final api = _FakeLightroomApi( + assets: [image('recent1', DateTime.utc(2026, 7, 6, 10, 30))], + ); + final summary = await service(api).poll(account: account, state: state); + + expect(summary.attached, 1); + expect((await mediaRepository.getMediaForDive(dive.id)), hasLength(1)); + expect(await state.lastPollAt(), DateTime.utc(2026, 7, 11, 12)); + }, + ); +} From 069660fc7cb38be39668d4c33eeca7f3c6d8cc57 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:48:27 -0400 Subject: [PATCH 14/22] feat(media): connector resolver fills the serviceConnector registry slot --- .../resolvers/connector_media_resolver.dart | 142 ++++++++++++++++ .../data/connector_media_resolver_test.dart | 159 ++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 lib/features/media/data/resolvers/connector_media_resolver.dart create mode 100644 test/features/media/data/connector_media_resolver_test.dart diff --git a/lib/features/media/data/resolvers/connector_media_resolver.dart b/lib/features/media/data/resolvers/connector_media_resolver.dart new file mode 100644 index 000000000..5975fed68 --- /dev/null +++ b/lib/features/media/data/resolvers/connector_media_resolver.dart @@ -0,0 +1,142 @@ +import 'dart:ui' show Size; + +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/services/media_source_resolver.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; +import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; +import 'package:submersion/core/services/media_store/store_keys.dart'; + +/// Resolver for `serviceConnector` media rows. Dispatches by the row's +/// connector type; v1 knows only Lightroom. +/// +/// This is the single source of connector bytes for BOTH display (via +/// MediaItemView) and store upload (MediaUploadPipeline materializes +/// through the registry). On devices without the connector account it +/// declines via [canResolveOnThisDevice]; those devices display through +/// the media store fallback instead. +/// +/// Rendition bytes are cached in the media store cache pools once the +/// row's contentHash is stamped by the upload pipeline: the rendition IS +/// this row's store content, so the hash of freshly-downloaded bytes can +/// be verified against it before entering the content-addressed cache. +class ConnectorMediaResolver implements MediaSourceResolver { + ConnectorMediaResolver({ + required bool hasLightroomAccount, + required Future Function() apiClient, + required Future Function() catalogId, + required Future Function() cache, + }) : _hasLightroomAccount = hasLightroomAccount, + _apiClient = apiClient, + _catalogId = catalogId, + _cache = cache; + + final bool _hasLightroomAccount; + final Future Function() _apiClient; + final Future Function() _catalogId; + final Future Function() _cache; + final _log = LoggerService.forClass(ConnectorMediaResolver); + + @override + MediaSourceType get sourceType => MediaSourceType.serviceConnector; + + @override + bool canResolveOnThisDevice(MediaItem item) => + _hasLightroomAccount && item.remoteAssetId != null; + + @override + Future resolve(MediaItem item) => + _resolveRendition(item, size: '2048', kind: MediaCacheKind.original); + + @override + Future resolveThumbnail( + MediaItem item, { + required Size target, + }) => + _resolveRendition(item, size: 'thumbnail2x', kind: MediaCacheKind.thumb); + + @override + Future extractMetadata(MediaItem item) async => null; + + @override + Future verify(MediaItem item) async { + if (!_hasLightroomAccount) return VerifyResult.unauthenticated; + if (item.remoteAssetId == null) return VerifyResult.notFound; + return VerifyResult.available; + } + + Future _resolveRendition( + MediaItem item, { + required String size, + required MediaCacheKind kind, + }) async { + final remoteAssetId = item.remoteAssetId; + if (remoteAssetId == null) { + return const UnavailableData(kind: UnavailableKind.notFound); + } + if (!_hasLightroomAccount) { + return const UnavailableData(kind: UnavailableKind.signInRequired); + } + + try { + final cache = await _cache(); + final contentHash = item.contentHash; + + if (cache != null && contentHash != null) { + final cached = await cache.get(contentHash, kind); + if (cached != null) return FileData(file: cached); + } + + final api = await _apiClient(); + final catalogId = await _catalogId(); + if (api == null || catalogId == null) { + return const UnavailableData(kind: UnavailableKind.signInRequired); + } + final bytes = await api.getRendition( + catalogId: catalogId, + assetId: remoteAssetId, + size: size, + ); + + // Enter the content-addressed cache only when the bytes provably ARE + // the row's content: full renditions verify against contentHash; + // thumbs are derived and keyed by the original's hash unverified, + // mirroring the store pipeline's thumb semantics. + if (cache != null && contentHash != null) { + final staging = await cache.stagingFile(); + try { + await staging.writeAsBytes(bytes, flush: true); + if (kind == MediaCacheKind.thumb) { + final file = await cache.put(contentHash, kind, staging); + return FileData(file: file); + } + final digest = await sha256OfFile(staging); + if (digest.hash == contentHash) { + final file = await cache.put(contentHash, kind, staging); + return FileData(file: file); + } + } finally { + if (await staging.exists()) { + await staging.delete(); + } + } + } + return BytesData(bytes: bytes); + } on LightroomApiException catch (e) { + _log.warning('Lightroom rendition $size failed for ${item.id}: $e'); + return UnavailableData( + kind: e.statusCode == 401 + ? UnavailableKind.unauthenticated + : UnavailableKind.networkError, + userMessage: e.message, + ); + } on Exception catch (e) { + _log.warning('Lightroom resolve failed for ${item.id}: $e'); + return const UnavailableData(kind: UnavailableKind.networkError); + } + } +} diff --git a/test/features/media/data/connector_media_resolver_test.dart b/test/features/media/data/connector_media_resolver_test.dart new file mode 100644 index 000000000..792a6f278 --- /dev/null +++ b/test/features/media/data/connector_media_resolver_test.dart @@ -0,0 +1,159 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' show Size; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/local_cache_database.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/media_store/store_keys.dart'; +import 'package:submersion/features/media/data/resolvers/connector_media_resolver.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; +import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; + +class _FakeRenditionApi extends LightroomApiClient { + _FakeRenditionApi({required this.bytes, this.statusCode}) + : super(auth: AdobeImsAuthManager()); + + final Uint8List bytes; + final int? statusCode; + int calls = 0; + + @override + Future getRendition({ + required String catalogId, + required String assetId, + required String size, + }) async { + calls++; + final status = statusCode; + if (status != null) { + throw LightroomApiException(status, 'error $status'); + } + return bytes; + } +} + +void main() { + late LocalCacheDatabase db; + late Directory root; + late MediaCacheStore cache; + + final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3]); + + setUp(() async { + db = LocalCacheDatabase(NativeDatabase.memory()); + root = await Directory.systemTemp.createTemp('cmr_test'); + cache = MediaCacheStore(database: db, root: root); + }); + + tearDown(() async { + await db.close(); + await root.delete(recursive: true); + }); + + ConnectorMediaResolver resolver( + _FakeRenditionApi api, { + bool hasAccount = true, + MediaCacheStore? withCache, + }) => ConnectorMediaResolver( + hasLightroomAccount: hasAccount, + apiClient: () async => api, + catalogId: () async => 'cat1', + cache: () async => withCache, + ); + + MediaItem item({String? contentHash, String? remoteAssetId = 'lr1'}) => + MediaItem( + id: 'm1', + mediaType: MediaType.photo, + sourceType: MediaSourceType.serviceConnector, + connectorAccountId: 'acct1', + remoteAssetId: remoteAssetId, + contentHash: contentHash, + takenAt: DateTime(2026), + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + test('declines on a device without the account', () async { + final api = _FakeRenditionApi(bytes: jpeg); + final r = resolver(api, hasAccount: false); + expect(r.canResolveOnThisDevice(item()), isFalse); + final data = await r.resolve(item()); + expect(data, isA()); + expect((data as UnavailableData).kind, UnavailableKind.signInRequired); + expect(await r.verify(item()), VerifyResult.unauthenticated); + }); + + test('fresh download without contentHash returns bytes', () async { + final api = _FakeRenditionApi(bytes: jpeg); + final data = await resolver(api).resolve(item()); + expect(data, isA()); + expect((data as BytesData).bytes, jpeg); + }); + + test('matching contentHash enters the cache; second resolve is a cache ' + 'hit', () async { + final api = _FakeRenditionApi(bytes: jpeg); + // Hash of the rendition bytes, computed the same way the pipeline does. + final staged = File('${root.path}/probe')..writeAsBytesSync(jpeg); + final digest = await sha256OfFile(staged); + + final r = resolver(api, withCache: cache); + final first = await r.resolve(item(contentHash: digest.hash)); + expect(first, isA()); + expect(api.calls, 1); + + final second = await r.resolve(item(contentHash: digest.hash)); + expect(second, isA()); + expect(api.calls, 1, reason: 'second resolve must hit the cache'); + }); + + test( + 'hash mismatch degrades to bytes and does not poison the cache', + () async { + final api = _FakeRenditionApi(bytes: jpeg); + final wrongHash = 'a' * 64; + final r = resolver(api, withCache: cache); + final data = await r.resolve(item(contentHash: wrongHash)); + expect(data, isA()); + expect(await cache.get(wrongHash, MediaCacheKind.original), isNull); + }, + ); + + test('thumbnail path caches under the thumb pool unverified', () async { + final api = _FakeRenditionApi(bytes: jpeg); + final hash = 'b' * 64; + final r = resolver(api, withCache: cache); + final data = await r.resolveThumbnail( + item(contentHash: hash), + target: const Size(200, 200), + ); + expect(data, isA()); + expect(await cache.get(hash, MediaCacheKind.thumb), isNotNull); + expect(api.calls, 1); + + await r.resolveThumbnail( + item(contentHash: hash), + target: const Size(200, 200), + ); + expect(api.calls, 1, reason: 'thumb re-resolve must hit the cache'); + }); + + test('401 maps to unauthenticated, 500 to networkError', () async { + final unauth = await resolver( + _FakeRenditionApi(bytes: jpeg, statusCode: 401), + ).resolve(item()); + expect((unauth as UnavailableData).kind, UnavailableKind.unauthenticated); + + final network = await resolver( + _FakeRenditionApi(bytes: jpeg, statusCode: 500), + ).resolve(item()); + expect((network as UnavailableData).kind, UnavailableKind.networkError); + }); +} From 6f5e3aba572eedd2883ddb859fde5389bba59bd0 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:52:47 -0400 Subject: [PATCH 15/22] feat(lightroom): riverpod wiring, serviceConnector registry registration, auto-poll provider --- .../providers/lightroom_providers.dart | 120 ++++++++++++++ .../providers/media_resolver_providers.dart | 4 + .../providers/media_store_providers.dart | 154 +++++++++--------- .../lightroom_providers_test.dart | 77 +++++++++ 4 files changed, 281 insertions(+), 74 deletions(-) create mode 100644 lib/features/media/presentation/providers/lightroom_providers.dart create mode 100644 test/features/media/presentation/lightroom_providers_test.dart diff --git a/lib/features/media/presentation/providers/lightroom_providers.dart b/lib/features/media/presentation/providers/lightroom_providers.dart new file mode 100644 index 000000000..9eff866d7 --- /dev/null +++ b/lib/features/media/presentation/providers/lightroom_providers.dart @@ -0,0 +1,120 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/media/data/repositories/connector_accounts_repository.dart'; +import 'package:submersion/features/media/data/resolvers/connector_media_resolver.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; +import 'package:submersion/features/media/data/services/lightroom_scan_service.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; +import 'package:submersion/features/media/domain/entities/media_item.dart' + as domain; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_store_enqueue_provider.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// Connector type key for Lightroom rows in `connector_accounts`. +const String lightroomConnectorType = 'lightroom'; + +/// Singleton IMS auth manager (mirrors the Dropbox provider singleton: +/// access-token cache and single-flight refresh must be process-wide). +final lightroomAuthManagerProvider = Provider( + (ref) => AdobeImsAuthManager(), +); + +final connectorAccountsRepositoryProvider = + Provider( + (ref) => ConnectorAccountsRepository(), + ); + +/// The device's Lightroom connection, or null when not connected. +/// Invalidate after connect/disconnect. +final lightroomAccountProvider = FutureProvider( + (ref) => ref + .watch(connectorAccountsRepositoryProvider) + .getByType(lightroomConnectorType), +); + +final lightroomApiClientProvider = Provider( + (ref) => LightroomApiClient(auth: ref.watch(lightroomAuthManagerProvider)), +); + +/// Per-account scan state (poll cursor, album filter, auto-poll, last +/// error), keyed by connector account id. +final lightroomConnectorStateProvider = + Provider.family( + (ref, accountId) => LightroomConnectorState( + prefs: ref.watch(sharedPreferencesProvider), + accountId: accountId, + ), + ); + +final lightroomScanServiceProvider = Provider( + (ref) => LightroomScanService( + api: ref.watch(lightroomApiClientProvider), + mediaRepository: ref.watch(mediaRepositoryProvider), + diveRepository: ref.watch(diveRepositoryProvider), + enrichmentService: ref.watch(enrichmentServiceProvider), + enqueueUpload: ref.watch(mediaStoreEnqueueProvider), + ), +); + +/// The `serviceConnector` slot of the resolver registry. +/// +/// Watches the account's loaded value on purpose: the registry (and with +/// it this resolver) rebuilds when the account future resolves, flipping +/// `hasLightroomAccount` from its initial false. Devices without an +/// account keep a declining resolver, which routes their display through +/// the media store fallback and keeps the upload pipeline from +/// enqueueing work it cannot materialize. +final connectorMediaResolverProvider = Provider((ref) { + final account = ref.watch(lightroomAccountProvider).value; + return ConnectorMediaResolver( + hasLightroomAccount: account != null, + apiClient: () async => + account == null ? null : ref.read(lightroomApiClientProvider), + catalogId: () async => account?.accountIdentifier, + cache: () => ref + .read(mediaStoreRuntimeProvider.future) + .then((runtime) => runtime?.cache), + ); +}); + +/// Live pending suggestions for a dive (gallery and connector alike). +final pendingSuggestionsForDiveProvider = + FutureProvider.family, String>( + (ref, diveId) => ref + .watch(mediaRepositoryProvider) + .getPendingSuggestionsForDive(diveId), + ); + +/// Fire-and-forget startup poll. Runs at most once per 6 hours per +/// device, only when an account exists and auto-poll is enabled. Errors +/// are recorded on the connector state and logged, never surfaced. +final lightroomAutoPollProvider = FutureProvider((ref) async { + final account = await ref.watch(lightroomAccountProvider.future); + if (account == null) return; + final state = ref.read(lightroomConnectorStateProvider(account.id)); + if (!await state.autoPollEnabled()) return; + final last = await state.lastPollAt(); + if (last != null && + DateTime.now().difference(last) < const Duration(hours: 6)) { + return; + } + try { + await ref + .read(lightroomScanServiceProvider) + .poll(account: account, state: state); + await state.setLastError(null); + } on Exception catch (e, st) { + await state.setLastError(e.toString()); + LoggerService.forClass( + LightroomScanService, + ).warning('Lightroom auto-poll failed: $e', stackTrace: st); + } +}); diff --git a/lib/features/media/presentation/providers/media_resolver_providers.dart b/lib/features/media/presentation/providers/media_resolver_providers.dart index 1033ce229..6a886b1fe 100644 --- a/lib/features/media/presentation/providers/media_resolver_providers.dart +++ b/lib/features/media/presentation/providers/media_resolver_providers.dart @@ -16,6 +16,7 @@ import 'package:submersion/features/media/data/services/network_credentials_serv import 'package:submersion/features/media/data/services/subscription_poller.dart'; import 'package:submersion/features/media/data/services/subscription_poller_scheduler.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; import 'package:submersion/features/media/presentation/providers/url_tab_providers.dart'; @@ -107,6 +108,9 @@ final mediaSourceResolverRegistryProvider = MediaSourceType.localFile: ref.watch(localFileResolverProvider), MediaSourceType.manifestEntry: ref.watch(manifestEntryResolverProvider), MediaSourceType.networkUrl: ref.watch(networkUrlMediaResolverProvider), + MediaSourceType.serviceConnector: ref.watch( + connectorMediaResolverProvider, + ), }); }); diff --git a/lib/features/media_store/presentation/providers/media_store_providers.dart b/lib/features/media_store/presentation/providers/media_store_providers.dart index 1520c53bd..f5cfe1c6e 100644 --- a/lib/features/media_store/presentation/providers/media_store_providers.dart +++ b/lib/features/media_store/presentation/providers/media_store_providers.dart @@ -127,80 +127,86 @@ final mediaStoreServiceProvider = Provider( /// store attached. Lazy: the first watcher (a media view or the settings /// page) triggers construction and a queue drain. Invalidate after connect /// or disconnect. -final mediaStoreRuntimeProvider = FutureProvider(( - ref, -) async { - final attachState = ref.watch(mediaStoreAttachStateProvider); - final attachedId = await attachState.attachedStoreId(); - if (attachedId == null) return null; - final providerType = - await attachState.attachedProviderType() ?? CloudProviderType.s3; - final s3Config = providerType == CloudProviderType.s3 - ? await ref.watch(mediaStoreCredentialsStoreProvider).load() - : null; - final store = await buildMediaObjectStore(providerType, s3Config: s3Config); - if (store == null) return null; - - final supportDir = await getApplicationSupportDirectory(); - final cache = MediaCacheStore( - database: LocalCacheDatabaseService.instance.database, - root: Directory(p.join(supportDir.path, 'Submersion', 'media_cache')), - ); - final resolver = MediaStoreResolver(store: store, cache: cache); - - final mediaRepository = ref.watch(mediaRepositoryProvider); - final policies = ref.watch(mediaStorePoliciesProvider); - final network = NetworkStatusService(); - final pipeline = MediaUploadPipeline( - mediaRepository: mediaRepository, - queue: MediaTransferQueueRepository(), - store: store, - registry: ref.watch(mediaSourceResolverRegistryProvider), - cache: cache, - ); - final worker = MediaStoreWorker( - queue: MediaTransferQueueRepository(), - pipeline: pipeline, - preflight: () async { - // Suspend all transfers when this device detached (attach state - // re-read, not captured: disconnect can land while a drain is - // running) or when the bucket no longer carries the store this - // device attached to (wiped or repointed; spec section 13). - final currentId = await attachState.attachedStoreId(); - if (currentId == null || currentId != attachedId) return false; - final marker = await StoreMarkerStore(store: store).read(); - return marker != null && marker.storeId == currentId; - }, - gate: (entry) async { - // Network policies (design spec section 9): offline halts the - // drain; cellular defers anything the policy disallows. - final kind = await network.current(); - if (kind == NetworkKind.offline) return WorkerGate.stopDraining; - if (kind == NetworkKind.cellular) { - final item = await mediaRepository.getMediaById(entry.mediaId); - final isVideo = item?.mediaType == MediaType.video; - final allowed = isVideo - ? await policies.videosOnCellular() - : await policies.photosOnCellular(); - if (!allowed) return WorkerGate.deferEntry; - } - return WorkerGate.proceed; - }, - ); - final connectivitySub = network.changes.listen((kind) { - if (kind != NetworkKind.offline) unawaited(worker.drain()); - }); - ref.onDispose(connectivitySub.cancel); - unawaited(worker.drain()); - - return MediaStoreRuntime( - storeId: attachedId, - store: store, - cache: cache, - resolver: resolver, - worker: worker, - ); -}); +// Explicit LHS type: this provider sits on an import cycle (resolver +// registry -> lightroom providers -> this file -> registry), and Dart's +// top-level inference cannot resolve initializer-inferred declarations +// that participate in a cycle. +final FutureProvider mediaStoreRuntimeProvider = + FutureProvider((ref) async { + final attachState = ref.watch(mediaStoreAttachStateProvider); + final attachedId = await attachState.attachedStoreId(); + if (attachedId == null) return null; + final providerType = + await attachState.attachedProviderType() ?? CloudProviderType.s3; + final s3Config = providerType == CloudProviderType.s3 + ? await ref.watch(mediaStoreCredentialsStoreProvider).load() + : null; + final store = await buildMediaObjectStore( + providerType, + s3Config: s3Config, + ); + if (store == null) return null; + + final supportDir = await getApplicationSupportDirectory(); + final cache = MediaCacheStore( + database: LocalCacheDatabaseService.instance.database, + root: Directory(p.join(supportDir.path, 'Submersion', 'media_cache')), + ); + final resolver = MediaStoreResolver(store: store, cache: cache); + + final mediaRepository = ref.watch(mediaRepositoryProvider); + final policies = ref.watch(mediaStorePoliciesProvider); + final network = NetworkStatusService(); + final pipeline = MediaUploadPipeline( + mediaRepository: mediaRepository, + queue: MediaTransferQueueRepository(), + store: store, + registry: ref.watch(mediaSourceResolverRegistryProvider), + cache: cache, + ); + final worker = MediaStoreWorker( + queue: MediaTransferQueueRepository(), + pipeline: pipeline, + preflight: () async { + // Suspend all transfers when this device detached (attach state + // re-read, not captured: disconnect can land while a drain is + // running) or when the bucket no longer carries the store this + // device attached to (wiped or repointed; spec section 13). + final currentId = await attachState.attachedStoreId(); + if (currentId == null || currentId != attachedId) return false; + final marker = await StoreMarkerStore(store: store).read(); + return marker != null && marker.storeId == currentId; + }, + gate: (entry) async { + // Network policies (design spec section 9): offline halts the + // drain; cellular defers anything the policy disallows. + final kind = await network.current(); + if (kind == NetworkKind.offline) return WorkerGate.stopDraining; + if (kind == NetworkKind.cellular) { + final item = await mediaRepository.getMediaById(entry.mediaId); + final isVideo = item?.mediaType == MediaType.video; + final allowed = isVideo + ? await policies.videosOnCellular() + : await policies.photosOnCellular(); + if (!allowed) return WorkerGate.deferEntry; + } + return WorkerGate.proceed; + }, + ); + final connectivitySub = network.changes.listen((kind) { + if (kind != NetworkKind.offline) unawaited(worker.drain()); + }); + ref.onDispose(connectivitySub.cancel); + unawaited(worker.drain()); + + return MediaStoreRuntime( + storeId: attachedId, + store: store, + cache: cache, + resolver: resolver, + worker: worker, + ); + }); /// The store-fallback resolver for display surfaces, or null when no store /// runtime exists yet. Synchronous accessor over the async runtime. diff --git a/test/features/media/presentation/lightroom_providers_test.dart b/test/features/media/presentation/lightroom_providers_test.dart new file mode 100644 index 000000000..bfe8665d8 --- /dev/null +++ b/test/features/media/presentation/lightroom_providers_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/media/data/services/media_source_resolver_registry.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +import '../../../helpers/test_database.dart'; + +void main() { + late ProviderContainer container; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + addTearDown(container.dispose); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + MediaItem connectorItem() => MediaItem( + id: 'm1', + mediaType: MediaType.photo, + sourceType: MediaSourceType.serviceConnector, + remoteAssetId: 'lr1', + takenAt: DateTime(2026), + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + test('registry resolves serviceConnector without UnsupportedError', () { + final MediaSourceResolverRegistry registry = container.read( + mediaSourceResolverRegistryProvider, + ); + final resolver = registry.resolverFor(MediaSourceType.serviceConnector); + expect(resolver.sourceType, MediaSourceType.serviceConnector); + }); + + test('resolver declines connector items while no account exists', () async { + // Let lightroomAccountProvider settle (null: no account row). + await container.read(lightroomAccountProvider.future); + final resolver = container.read(connectorMediaResolverProvider); + expect(resolver.canResolveOnThisDevice(connectorItem()), isFalse); + }); + + test('account creation flips the resolver after invalidation', () async { + await container.read(lightroomAccountProvider.future); + await container + .read(connectorAccountsRepositoryProvider) + .create( + connectorType: lightroomConnectorType, + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + ); + container.invalidate(lightroomAccountProvider); + await container.read(lightroomAccountProvider.future); + + final resolver = container.read(connectorMediaResolverProvider); + expect(resolver.canResolveOnThisDevice(connectorItem()), isTrue); + }); + + test('auto-poll is a no-op without an account', () async { + await container.read(lightroomAutoPollProvider.future); + // Nothing to assert beyond not throwing: no account means no state + // writes and no API construction. + }); +} From 120113315ddc95b6d831fb38abaf7fdd1d21e563 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 07:57:33 -0400 Subject: [PATCH 16/22] feat(media-store): serviceConnector upload eligibility with thumb-only videos --- .../data/repositories/media_repository.dart | 15 +- .../data/media_upload_pipeline.dart | 19 ++- .../media_store/data/thumbnail_generator.dart | 7 + .../media_backfill_service_test.dart | 29 ++++ .../media_upload_pipeline_test.dart | 131 ++++++++++++++++++ 5 files changed, 197 insertions(+), 4 deletions(-) diff --git a/lib/features/media/data/repositories/media_repository.dart b/lib/features/media/data/repositories/media_repository.dart index 948d07e99..0b3525fec 100644 --- a/lib/features/media/data/repositories/media_repository.dart +++ b/lib/features/media/data/repositories/media_repository.dart @@ -892,9 +892,18 @@ class MediaRepository { final query = _db.selectOnly(_db.media) ..addColumns([id]) ..where( - _db.media.remoteUploadedAt.isNull() & - _db.media.fileType.equals('photo') & - _db.media.sourceType.isIn(['platformGallery', 'localFile']), + (_db.media.remoteUploadedAt.isNull() & + _db.media.fileType.equals('photo') & + _db.media.sourceType.isIn([ + 'platformGallery', + 'localFile', + 'serviceConnector', + ])) | + // Connector videos are thumb-only (no original in the store by + // design), so their backfill signal is the missing thumb stamp. + (_db.media.remoteThumbUploadedAt.isNull() & + _db.media.fileType.equals('video') & + _db.media.sourceType.equals('serviceConnector')), ) ..orderBy([OrderingTerm.desc(_db.media.takenAt)]); final rows = await query.get(); diff --git a/lib/features/media_store/data/media_upload_pipeline.dart b/lib/features/media_store/data/media_upload_pipeline.dart index 893882485..86b0ebd8c 100644 --- a/lib/features/media_store/data/media_upload_pipeline.dart +++ b/lib/features/media_store/data/media_upload_pipeline.dart @@ -49,8 +49,18 @@ class MediaUploadPipeline { static const Set _eligibleSources = { MediaSourceType.platformGallery, MediaSourceType.localFile, + MediaSourceType.serviceConnector, }; + /// Connector videos never download their original in v1 (Lightroom spec: + /// match + thumbnail only). The store carries just the thumb, derived + /// from the poster rendition the resolver materializes, and + /// remoteUploadedAt stays null so a future playback phase can tell a + /// poster frame from a playable original. + bool _isThumbOnly(MediaItem item) => + item.sourceType == MediaSourceType.serviceConnector && + item.mediaType == MediaType.video; + Future process(MediaTransferQueueEntry entry) async { await _queue.markTransferring(entry.id); final item = await _mediaRepository.getMediaById(entry.mediaId); @@ -58,7 +68,9 @@ class MediaUploadPipeline { await _queue.markDone(entry.id); return UploadOutcome.skippedIneligible; } - if (item.remoteUploadedAt != null) { + if (_isThumbOnly(item) + ? item.remoteThumbUploadedAt != null + : item.remoteUploadedAt != null) { await _queue.markDone(entry.id); return UploadOutcome.deduplicated; } @@ -109,6 +121,11 @@ class MediaUploadPipeline { } } + if (_isThumbOnly(item)) { + await _queue.markDone(entry.id); + return UploadOutcome.uploaded; + } + final extension = StoreKeys.extensionFor(item.originalFilename); final key = StoreKeys.objectKey(digest.hash, extension: extension); final existing = await _store.head(key); diff --git a/lib/features/media_store/data/thumbnail_generator.dart b/lib/features/media_store/data/thumbnail_generator.dart index 739cbc711..a221213a5 100644 --- a/lib/features/media_store/data/thumbnail_generator.dart +++ b/lib/features/media_store/data/thumbnail_generator.dart @@ -45,6 +45,13 @@ class ThumbnailGenerator { final staged = await _cache.stagingFile(); await staged.writeAsBytes(b, flush: true); return staged; + case BytesData(bytes: final b) + when item.sourceType == MediaSourceType.serviceConnector: + // Connector renditions are always JPEG regardless of the + // original's filename: a video row carries a .mp4 name but its + // rendition is a JPEG poster frame, and decoding by that name + // would always fail. + return _resizeToJpeg(b, 'rendition.jpg'); case BytesData(bytes: final b): // Non-gallery BytesData is the original (e.g. a bookmark read on // iOS/macOS): resize and re-encode so full-size bytes and their diff --git a/test/features/media_store/media_backfill_service_test.dart b/test/features/media_store/media_backfill_service_test.dart index c46a732ad..1d3c94b7f 100644 --- a/test/features/media_store/media_backfill_service_test.dart +++ b/test/features/media_store/media_backfill_service_test.dart @@ -85,4 +85,33 @@ void main() { expect(await service.enqueueAll(), 2); expect((await queue.allForTesting()).length, 2); }); + + test('connector photos are candidates; connector videos key on the thumb ' + 'stamp', () async { + final photo = await mediaRow( + name: 'lr.jpg', + sourceType: MediaSourceType.serviceConnector, + ); + final videoNoThumb = await mediaRow( + name: 'lr1.mp4', + mediaType: domain.MediaType.video, + sourceType: MediaSourceType.serviceConnector, + takenAt: DateTime(2025, 12), + ); + final videoWithThumb = await mediaRow( + name: 'lr2.mp4', + mediaType: domain.MediaType.video, + sourceType: MediaSourceType.serviceConnector, + ); + await mediaRepository.stampRemoteThumbUploaded( + videoWithThumb.id, + uploadedAt: DateTime(2026, 7), + ); + // A gallery video stays excluded even though connector videos now + // qualify. + await mediaRow(name: 'gal.mp4', mediaType: domain.MediaType.video); + + final ids = await mediaRepository.getBackfillCandidateIds(); + expect(ids, [photo.id, videoNoThumb.id]); + }); } diff --git a/test/features/media_store/media_upload_pipeline_test.dart b/test/features/media_store/media_upload_pipeline_test.dart index 9129e565a..99014563f 100644 --- a/test/features/media_store/media_upload_pipeline_test.dart +++ b/test/features/media_store/media_upload_pipeline_test.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'dart:io'; + +import 'package:image/image.dart' as img; import 'dart:ui' show Size; import 'package:drift/native.dart'; @@ -438,4 +440,133 @@ void main() { expect(row.errorMessage, isNotNull); expect(failingStore.objects, isEmpty); }); + + group('serviceConnector rows', () { + late _FakeLocalFileResolver connectorResolver; + late MediaUploadPipeline connectorPipeline; + + setUp(() { + // Same fake shape, registered under serviceConnector: the pipeline + // only cares that the registry materializes bytes for the type. + connectorResolver = _FakeConnectorResolver( + const UnavailableData(kind: UnavailableKind.notFound), + ); + final registry = MediaSourceResolverRegistry({ + MediaSourceType.serviceConnector: connectorResolver, + }); + connectorPipeline = MediaUploadPipeline( + mediaRepository: mediaRepository, + queue: queue, + store: fakeStore, + registry: registry, + cache: cache, + thumbnails: ThumbnailGenerator(registry: registry, cache: cache), + now: () => DateTime(2026, 7, 10, 12), + ); + }); + + // Connector renditions are JPEG regardless of the original's name. + List jpegRendition() => img.encodeJpg(img.Image(width: 2, height: 2)); + + Future enqueueConnectorItem({ + required domain.MediaType mediaType, + required String name, + }) async { + connectorResolver.data = BytesData( + bytes: Uint8List.fromList(jpegRendition()), + ); + connectorResolver.thumbnailData = BytesData( + bytes: Uint8List.fromList(jpegRendition()), + ); + final created = await mediaRepository.createMedia( + domain.MediaItem( + id: '', + mediaType: mediaType, + sourceType: MediaSourceType.serviceConnector, + connectorAccountId: 'acct1', + remoteAssetId: 'lr-$name', + originalFilename: name, + takenAt: DateTime(2026, 1, 1), + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ), + ); + await queue.enqueueUpload(mediaId: created.id); + return created.id; + } + + test( + 'connector photo uploads rendition bytes and stamps the row', + () async { + final id = await enqueueConnectorItem( + mediaType: domain.MediaType.photo, + name: 'reef.jpg', + ); + final entry = (await queue.nextPending(DateTime.now()))!; + expect(await connectorPipeline.process(entry), UploadOutcome.uploaded); + + final item = (await mediaRepository.getMediaById(id))!; + expect(item.contentHash, isNotNull); + expect(item.remoteUploadedAt, isNotNull); + expect(item.remoteThumbUploadedAt, isNotNull); + final key = + 'smv1/objects/${item.contentHash!.substring(0, 2)}/' + '${item.contentHash}.jpg'; + expect(fakeStore.objects[key], jpegRendition()); + }, + ); + + test( + 'connector video is thumb-only: thumb stamped, no original object', + () async { + final id = await enqueueConnectorItem( + mediaType: domain.MediaType.video, + name: 'clip.mp4', + ); + final entry = (await queue.nextPending(DateTime.now()))!; + expect(await connectorPipeline.process(entry), UploadOutcome.uploaded); + + final item = (await mediaRepository.getMediaById(id))!; + expect(item.contentHash, isNotNull); + expect(item.remoteThumbUploadedAt, isNotNull); + expect( + item.remoteUploadedAt, + isNull, + reason: 'no original in the store means no upload confirmation', + ); + expect( + fakeStore.objects.keys, + everyElement(startsWith('smv1/thumbs/')), + reason: 'only the thumb object may exist for a connector video', + ); + expect((await queue.allForTesting()).single.state, 'done'); + }, + ); + + test('already thumb-stamped connector video short-circuits as ' + 'deduplicated', () async { + final id = await enqueueConnectorItem( + mediaType: domain.MediaType.video, + name: 'clip2.mp4', + ); + final first = (await queue.nextPending(DateTime.now()))!; + await connectorPipeline.process(first); + + await queue.enqueueUpload(mediaId: id); + final second = (await queue.nextPending(DateTime.now()))!; + expect( + await connectorPipeline.process(second), + UploadOutcome.deduplicated, + ); + }); + }); +} + +/// The connector twin of [_FakeLocalFileResolver]; only the source type +/// differs. +class _FakeConnectorResolver extends _FakeLocalFileResolver { + _FakeConnectorResolver(super.data); + + @override + MediaSourceType get sourceType => MediaSourceType.serviceConnector; } From 265e58e5c691a04dd9138a7b910c481969c4f541 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 08:06:04 -0400 Subject: [PATCH 17/22] feat(lightroom): connect flow, settings page, route, and localized strings (11 locales) --- lib/core/router/app_router.dart | 6 + .../helpers/lightroom_scan_helper.dart | 59 +++ .../pages/lightroom_settings_page.dart | 345 ++++++++++++++++++ .../presentation/pages/settings_page.dart | 8 + .../widgets/lightroom_connect_dialog.dart | 160 ++++++++ lib/l10n/arb/app_ar.arb | 29 ++ lib/l10n/arb/app_de.arb | 29 ++ lib/l10n/arb/app_en.arb | 70 ++++ lib/l10n/arb/app_es.arb | 29 ++ lib/l10n/arb/app_fr.arb | 29 ++ lib/l10n/arb/app_he.arb | 29 ++ lib/l10n/arb/app_hu.arb | 29 ++ lib/l10n/arb/app_it.arb | 29 ++ lib/l10n/arb/app_localizations.dart | 178 +++++++++ lib/l10n/arb/app_localizations_ar.dart | 108 ++++++ lib/l10n/arb/app_localizations_de.dart | 111 ++++++ lib/l10n/arb/app_localizations_en.dart | 108 ++++++ lib/l10n/arb/app_localizations_es.dart | 108 ++++++ lib/l10n/arb/app_localizations_fr.dart | 109 ++++++ lib/l10n/arb/app_localizations_he.dart | 106 ++++++ lib/l10n/arb/app_localizations_hu.dart | 110 ++++++ lib/l10n/arb/app_localizations_it.dart | 110 ++++++ lib/l10n/arb/app_localizations_nl.dart | 110 ++++++ lib/l10n/arb/app_localizations_pt.dart | 109 ++++++ lib/l10n/arb/app_localizations_zh.dart | 103 ++++++ lib/l10n/arb/app_nl.arb | 29 ++ lib/l10n/arb/app_pt.arb | 29 ++ lib/l10n/arb/app_zh.arb | 29 ++ .../lightroom_connect_dialog_test.dart | 126 +++++++ 29 files changed, 2434 insertions(+) create mode 100644 lib/features/media/presentation/helpers/lightroom_scan_helper.dart create mode 100644 lib/features/settings/presentation/pages/lightroom_settings_page.dart create mode 100644 lib/features/settings/presentation/widgets/lightroom_connect_dialog.dart create mode 100644 test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d1c688cf6..292b04632 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -78,6 +78,7 @@ import 'package:submersion/features/backup/presentation/pages/backup_settings_pa import 'package:submersion/features/settings/presentation/pages/cloud_sync_page.dart'; import 'package:submersion/features/media_store/presentation/pages/media_storage_page.dart'; import 'package:submersion/features/media_store/presentation/pages/transfers_page.dart'; +import 'package:submersion/features/settings/presentation/pages/lightroom_settings_page.dart'; import 'package:submersion/features/settings/presentation/pages/s3_config_page.dart'; import 'package:submersion/features/settings/presentation/pages/fix_dive_times_page.dart'; import 'package:submersion/features/settings/presentation/pages/settings_page.dart'; @@ -918,6 +919,11 @@ final appRouterProvider = Provider((ref) { ), ], ), + GoRoute( + path: 'lightroom', + name: 'lightroom', + builder: (context, state) => const LightroomSettingsPage(), + ), GoRoute( path: 'fix-dive-times', name: 'fixDiveTimes', diff --git a/lib/features/media/presentation/helpers/lightroom_scan_helper.dart b/lib/features/media/presentation/helpers/lightroom_scan_helper.dart new file mode 100644 index 000000000..d8a6b3395 --- /dev/null +++ b/lib/features/media/presentation/helpers/lightroom_scan_helper.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Runs a Lightroom scan over [dives] with progress and summary snackbars. +/// Writes/clears the connector's last-error state (the settings page's +/// needs-reauth chip reads it). No-op when Lightroom is not connected. +Future runLightroomScan( + BuildContext context, + WidgetRef ref, + List dives, +) async { + final account = await ref.read(lightroomAccountProvider.future); + if (account == null || !context.mounted) return; + final state = ref.read(lightroomConnectorStateProvider(account.id)); + final service = ref.read(lightroomScanServiceProvider); + final messenger = ScaffoldMessenger.of(context); + final l10n = context.l10n; + + messenger.showSnackBar( + SnackBar(content: Text(l10n.settings_lightroom_scan_running)), + ); + try { + final summary = await service.scanDives( + account: account, + dives: dives, + state: state, + ); + await state.setLastError(null); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text( + l10n.settings_lightroom_scan_summary( + summary.attached, + summary.suggested, + summary.skippedExisting, + ), + ), + ), + ); + for (final dive in dives) { + ref.invalidate(pendingSuggestionsForDiveProvider(dive.id)); + ref.invalidate(mediaForDiveProvider(dive.id)); + } + } on Exception catch (e) { + final message = e is CloudStorageException + ? e.displayMessage + : e.toString(); + await state.setLastError(message); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar(content: Text(message))); + } +} diff --git a/lib/features/settings/presentation/pages/lightroom_settings_page.dart b/lib/features/settings/presentation/pages/lightroom_settings_page.dart new file mode 100644 index 000000000..6e8e3a5a8 --- /dev/null +++ b/lib/features/settings/presentation/pages/lightroom_settings_page.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; +import 'package:submersion/core/services/lightroom/lightroom_models.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; +import 'package:submersion/features/media/presentation/helpers/lightroom_scan_helper.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/lightroom_connect_dialog.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Settings page for the Adobe Lightroom connector: BYO-credentials +/// connect flow when disconnected; account, album filter, auto-poll, +/// manual scan, and disconnect when connected. +class LightroomSettingsPage extends ConsumerStatefulWidget { + const LightroomSettingsPage({super.key}); + + @override + ConsumerState createState() => + _LightroomSettingsPageState(); +} + +class _LightroomSettingsPageState extends ConsumerState { + final _clientIdController = TextEditingController(); + final _clientSecretController = TextEditingController(); + bool _busy = false; + + @override + void dispose() { + _clientIdController.dispose(); + _clientSecretController.dispose(); + super.dispose(); + } + + Future _connect() async { + final l10n = context.l10n; + final authManager = ref.read(lightroomAuthManagerProvider); + final clientId = _clientIdController.text.trim(); + if (clientId.isEmpty) return; + + final connected = await showDialog( + context: context, + builder: (_) => LightroomConnectDialog( + authManager: authManager, + clientId: clientId, + clientSecret: _clientSecretController.text, + ), + ); + if (connected != true || !mounted) return; + + setState(() => _busy = true); + try { + // Fetch identity and catalog, persist them on the auth blob, then + // create the connector account row that flips every provider. + final api = ref.read(lightroomApiClientProvider); + final account = await api.getAccount(); + final catalogId = await api.getCatalogId(); + final auth = await authManager.loadAuth(); + if (auth != null) { + await authManager.updateAuth( + auth.copyWith( + catalogId: catalogId, + displayName: account.fullName, + email: account.email, + ), + ); + } + await ref + .read(connectorAccountsRepositoryProvider) + .create( + connectorType: lightroomConnectorType, + displayName: account.fullName ?? account.email ?? 'Adobe account', + credentialsRef: LightroomAuthStore.storageKey, + accountIdentifier: catalogId, + ); + ref.invalidate(lightroomAccountProvider); + } on Exception catch (e) { + if (!mounted) return; + final message = switch (e) { + CloudStorageException(:final displayMessage) => displayMessage, + _ => e.toString(), + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.settings_lightroom_connect_failed(message)), + ), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _disconnect(domain.ConnectorAccount account) async { + final l10n = context.l10n; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.settings_lightroom_disconnect_confirmTitle), + content: Text(l10n.settings_lightroom_disconnect_confirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(MaterialLocalizations.of(ctx).cancelButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.settings_lightroom_disconnect), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + await ref.read(lightroomAuthManagerProvider).disconnect(); + await ref.read(lightroomConnectorStateProvider(account.id)).clear(); + await ref.read(connectorAccountsRepositoryProvider).delete(account.id); + ref.invalidate(lightroomAccountProvider); + } + + Future _editAlbumFilter(domain.ConnectorAccount account) async { + final l10n = context.l10n; + final catalogId = account.accountIdentifier; + if (catalogId == null) return; + final state = ref.read(lightroomConnectorStateProvider(account.id)); + final selected = (await state.albumIds()).toSet(); + if (!mounted) return; + + final api = ref.read(lightroomApiClientProvider); + final result = await showDialog>( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.settings_lightroom_albumFilter_title), + content: SizedBox( + width: 360, + child: FutureBuilder>( + future: api.listAlbums(catalogId), + builder: (ctx, snapshot) { + final albums = snapshot.data; + if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } + if (albums == null) { + return const Center(child: CircularProgressIndicator()); + } + return StatefulBuilder( + builder: (ctx, setDialogState) => ListView( + shrinkWrap: true, + children: [ + for (final album in albums) + CheckboxListTile( + title: Text(album.name), + value: selected.contains(album.id), + onChanged: (checked) => setDialogState(() { + if (checked == true) { + selected.add(album.id); + } else { + selected.remove(album.id); + } + }), + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(MaterialLocalizations.of(ctx).cancelButtonLabel), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(selected), + child: Text(MaterialLocalizations.of(ctx).okButtonLabel), + ), + ], + ), + ); + if (result == null) return; + await state.setAlbumIds(result.toList()); + if (mounted) setState(() {}); + } + + Future _scanAll() async { + final dives = await ref.read(diveRepositoryProvider).getAllDives(); + if (!mounted) return; + await runLightroomScan(context, ref, dives); + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final accountAsync = ref.watch(lightroomAccountProvider); + return Scaffold( + appBar: AppBar(title: Text(l10n.settings_lightroom_title)), + body: accountAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text(e.toString())), + data: (account) => account == null + ? _disconnectedBody(l10n) + : _connectedBody(l10n, account), + ), + ); + } + + Widget _disconnectedBody(AppLocalizations l10n) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(l10n.settings_lightroom_subtitle), + const SizedBox(height: 16), + TextField( + controller: _clientIdController, + enabled: !_busy, + decoration: InputDecoration( + labelText: l10n.settings_lightroom_clientId_label, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _clientSecretController, + enabled: !_busy, + obscureText: true, + decoration: InputDecoration( + labelText: l10n.settings_lightroom_clientSecret_label, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Text( + l10n.settings_lightroom_clientId_help( + AdobeImsAuthManager.redirectUri, + ), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _busy || _clientIdController.text.trim().isEmpty + ? null + : _connect, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.link), + label: Text(l10n.settings_lightroom_connect), + ), + ], + ); + } + + Widget _connectedBody( + AppLocalizations l10n, + domain.ConnectorAccount account, + ) { + final state = ref.read(lightroomConnectorStateProvider(account.id)); + return ListView( + children: [ + ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: Text(l10n.settings_lightroom_connected(account.displayName)), + subtitle: FutureBuilder( + future: state.lastPollAt(), + builder: (_, snapshot) { + final last = snapshot.data; + if (last == null) return const SizedBox.shrink(); + return Text( + l10n.settings_lightroom_lastPoll( + MaterialLocalizations.of( + context, + ).formatShortDate(last.toLocal()), + ), + ); + }, + ), + trailing: FutureBuilder( + future: state.lastError(), + builder: (_, snapshot) => snapshot.data == null + ? const SizedBox.shrink() + : Chip( + label: Text(l10n.settings_lightroom_needsReauth), + backgroundColor: Theme.of( + context, + ).colorScheme.errorContainer, + ), + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.photo_album_outlined), + title: Text(l10n.settings_lightroom_albumFilter_title), + subtitle: FutureBuilder>( + future: state.albumIds(), + builder: (_, snapshot) => Text( + (snapshot.data?.isEmpty ?? true) + ? l10n.settings_lightroom_albumFilter_all + : '${snapshot.data!.length}', + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _editAlbumFilter(account), + ), + FutureBuilder( + future: state.autoPollEnabled(), + builder: (_, snapshot) => SwitchListTile( + secondary: const Icon(Icons.autorenew), + title: Text(l10n.settings_lightroom_autoPoll_title), + value: snapshot.data ?? true, + onChanged: (enabled) async { + await state.setAutoPollEnabled(enabled); + if (mounted) setState(() {}); + }, + ), + ), + ListTile( + leading: const Icon(Icons.cloud_sync_outlined), + title: Text(l10n.settings_lightroom_scanNow), + onTap: _scanAll, + ), + const Divider(), + ListTile( + leading: Icon( + Icons.link_off, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + l10n.settings_lightroom_disconnect, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () => _disconnect(account), + ), + ], + ); + } +} diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 52b13b1d5..1bd8f3255 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1978,6 +1978,14 @@ class _DataSectionContent extends ConsumerWidget { trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/settings/media-storage'), ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: Text(context.l10n.settings_lightroom_title), + subtitle: Text(context.l10n.settings_lightroom_subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/settings/lightroom'), + ), ], ), ), diff --git a/lib/features/settings/presentation/widgets/lightroom_connect_dialog.dart b/lib/features/settings/presentation/widgets/lightroom_connect_dialog.dart new file mode 100644 index 000000000..51a09b86f --- /dev/null +++ b/lib/features/settings/presentation/widgets/lightroom_connect_dialog.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The paste-the-redirected-URL OAuth dialog: opens the Adobe IMS +/// authorize page in the system browser and exchanges the pasted URL (or +/// raw code). Pops `true` on success. +/// +/// [openUri] is injectable for widget tests; production uses url_launcher. +class LightroomConnectDialog extends StatefulWidget { + const LightroomConnectDialog({ + required this.authManager, + required this.clientId, + this.clientSecret, + this.openUri, + super.key, + }); + + final AdobeImsAuthManager authManager; + final String clientId; + final String? clientSecret; + final Future Function(Uri uri)? openUri; + + @override + State createState() => _LightroomConnectDialogState(); +} + +class _LightroomConnectDialogState extends State { + final _codeController = TextEditingController(); + Uri? _authorizeUri; + String? _errorText; + bool _connecting = false; + + @override + void initState() { + super.initState(); + // beginAuthorization generates the PKCE verifier; the same URI (and + // verifier) is reused by "Reopen browser" so the pasted code always + // matches the pending verifier. + WidgetsBinding.instance.addPostFrameCallback((_) => _openBrowser()); + } + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + Future _openBrowser() async { + try { + final uri = _authorizeUri ??= widget.authManager.beginAuthorization( + clientId: widget.clientId, + clientSecret: widget.clientSecret, + ); + final open = + widget.openUri ?? + (Uri u) => launchUrl(u, mode: LaunchMode.externalApplication); + final opened = await open(uri); + if (!mounted) return; + // launchUrl reports failure by returning false, not only by throwing. + // A successful (re)open clears any stale error. + setState( + () => _errorText = opened + ? null + : context.l10n.settings_cloudSync_dropbox_connect_browserFailed, + ); + } on CloudStorageException catch (e) { + // The dialog is barrier-dismissible; the open can outlive this State. + if (!mounted) return; + setState(() => _errorText = e.displayMessage); + } catch (e) { + if (!mounted) return; + setState(() => _errorText = e.toString()); + } + } + + Future _connect() async { + final l10n = context.l10n; + final input = _codeController.text.trim(); + if (input.isEmpty) { + setState(() => _errorText = l10n.settings_lightroom_connect_emptyCode); + return; + } + setState(() { + _connecting = true; + _errorText = null; + }); + try { + await widget.authManager.completeAuthorization(input); + if (mounted) Navigator.of(context).pop(true); + } on CloudStorageException catch (e) { + if (!mounted) return; + setState(() { + _connecting = false; + _errorText = l10n.settings_lightroom_connect_failed(e.displayMessage); + }); + } catch (e) { + // The final store save can throw a raw PlatformException from the + // keychain; without this catch the dialog wedges with _connecting + // stuck true. + if (!mounted) return; + setState(() { + _connecting = false; + _errorText = l10n.settings_lightroom_connect_failed(e.toString()); + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(l10n.settings_lightroom_connect_title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.settings_lightroom_connect_instructions), + const SizedBox(height: 16), + TextField( + controller: _codeController, + autofocus: true, + enabled: !_connecting, + decoration: InputDecoration( + labelText: l10n.settings_lightroom_connect_codeLabel, + errorText: _errorText, + errorMaxLines: 3, + ), + onSubmitted: (_) => _connect(), + ), + ], + ), + actions: [ + TextButton( + onPressed: _connecting ? null : _openBrowser, + child: Text(l10n.settings_lightroom_connect_reopenBrowser), + ), + TextButton( + onPressed: _connecting + ? null + : () => Navigator.of(context).pop(false), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + FilledButton( + onPressed: _connecting ? null : _connect, + child: _connecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.settings_lightroom_connect_submit), + ), + ], + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 70b410e5c..cf6a23086 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3259,6 +3259,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{تم استيراد صورة واحدة} other{تم استيراد {imported} صورة}} ({skipped} مرتبطة بالفعل)", "media_import_importedPhotos": "تم استيراد {count} {count, plural, =1{صورة} other{صور}}", "media_import_importingPhotos": "جارٍ استيراد {count} {count, plural, =1{صورة} other{صور}}...", + "media_lightroom_openInLightroom": "فتح في Lightroom", + "media_lightroom_suggestion_accept": "إضافة إلى هذه الغطسة", + "media_lightroom_suggestion_dismiss": "تجاهل", + "media_lightroom_suggestions_title": "اقتراحات من Lightroom", "media_miniProfile_headerLabel": "ملف الغوصة", "media_miniProfile_semanticLabel": "مخطط ملف الغوصة المصغر", "media_photoPicker_appBarTitle": "اختيار الصور", @@ -3767,6 +3771,31 @@ "settings_language_appBar_title": "اللغة", "settings_language_selected": "محدد", "settings_language_systemDefault": "الافتراضي للنظام", + "settings_lightroom_albumFilter_all": "الكتالوج بأكمله", + "settings_lightroom_albumFilter_title": "الألبومات المراد فحصها", + "settings_lightroom_autoPoll_title": "التحقق من الصور الجديدة تلقائيًا", + "settings_lightroom_clientId_help": "أنشئ تكاملًا في Adobe Developer Console باستخدام واجهة Lightroom Services API ونوع اعتماد يدعم PKCE. عيّن عنوان إعادة التوجيه إلى {redirectUri}.", + "settings_lightroom_clientId_label": "معرّف عميل Adobe", + "settings_lightroom_clientSecret_label": "سر العميل (اختياري)", + "settings_lightroom_connect": "ربط Lightroom", + "settings_lightroom_connect_codeLabel": "عنوان URL المُعاد توجيهه أو الرمز", + "settings_lightroom_connect_emptyCode": "الصق عنوان URL المُعاد توجيهه أو رمز التفويض", + "settings_lightroom_connect_failed": "تعذّر الاتصال بـ Lightroom: {error}", + "settings_lightroom_connect_instructions": "سجّل الدخول إلى Adobe في نافذة المتصفح، ثم الصق العنوان الكامل للصفحة التي تصل إليها (فهو يحتوي على رمز التفويض).", + "settings_lightroom_connect_reopenBrowser": "إعادة فتح المتصفح", + "settings_lightroom_connect_submit": "ربط", + "settings_lightroom_connect_title": "ربط Lightroom", + "settings_lightroom_connected": "متصل باسم {name}", + "settings_lightroom_disconnect": "قطع الاتصال", + "settings_lightroom_disconnect_confirmBody": "تبقى الصور المرتبطة في غطساتك وتستمر في الظهور من مخزن الوسائط. لن تتم مطابقة الصور الجديدة بعد الآن.", + "settings_lightroom_disconnect_confirmTitle": "قطع الاتصال بـ Lightroom؟", + "settings_lightroom_lastPoll": "آخر فحص: {when}", + "settings_lightroom_needsReauth": "يلزم إعادة الاتصال", + "settings_lightroom_scanNow": "فحص Lightroom", + "settings_lightroom_scan_running": "جارٍ فحص Lightroom...", + "settings_lightroom_scan_summary": "{attached} مرتبطة، {suggested} مقترحة، {skipped} مرتبطة بالفعل", + "settings_lightroom_subtitle": "ربط الصور ومقاطع الفيديو بالغطسات تلقائيًا", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "قوالب قوائم التحقق", "settings_manage_checklistTemplates_subtitle": "قوائم مهام قابلة لإعادة الاستخدام للتخطيط للرحلات", "settings_manage_diveRoles": "أدوار الغوص", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 84ba3da89..591575609 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3259,6 +3259,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{1 Foto importiert} other{{imported} Fotos importiert}} ({skipped} bereits verknüpft)", "media_import_importedPhotos": "{count} {count, plural, =1{Foto} other{Fotos}} importiert", "media_import_importingPhotos": "{count} {count, plural, =1{Foto} other{Fotos}} werden importiert...", + "media_lightroom_openInLightroom": "In Lightroom öffnen", + "media_lightroom_suggestion_accept": "Zu diesem Tauchgang hinzufügen", + "media_lightroom_suggestion_dismiss": "Verwerfen", + "media_lightroom_suggestions_title": "Vorschläge aus Lightroom", "media_miniProfile_headerLabel": "Tauchprofil", "media_miniProfile_semanticLabel": "Mini-Tauchprofildiagramm", "media_photoPicker_appBarTitle": "Fotos auswählen", @@ -3767,6 +3771,31 @@ "settings_language_appBar_title": "Sprache", "settings_language_selected": "Ausgewählt", "settings_language_systemDefault": "Systemstandard", + "settings_lightroom_albumFilter_all": "Gesamter Katalog", + "settings_lightroom_albumFilter_title": "Zu durchsuchende Alben", + "settings_lightroom_autoPoll_title": "Automatisch nach neuen Fotos suchen", + "settings_lightroom_clientId_help": "Erstelle in der Adobe Developer Console eine Integration mit der Lightroom Services API und einem Anmeldetyp, der PKCE unterstützt. Lege als Redirect-URI {redirectUri} fest.", + "settings_lightroom_clientId_label": "Adobe Client-ID", + "settings_lightroom_clientSecret_label": "Client-Secret (optional)", + "settings_lightroom_connect": "Lightroom verbinden", + "settings_lightroom_connect_codeLabel": "Weitergeleitete URL oder Code", + "settings_lightroom_connect_emptyCode": "Weitergeleitete URL oder Autorisierungscode einfügen", + "settings_lightroom_connect_failed": "Verbindung zu Lightroom fehlgeschlagen: {error}", + "settings_lightroom_connect_instructions": "Melde dich im Browserfenster bei Adobe an und füge dann die vollständige Adresse der Seite ein, auf der du landest (sie enthält den Autorisierungscode).", + "settings_lightroom_connect_reopenBrowser": "Browser erneut öffnen", + "settings_lightroom_connect_submit": "Verbinden", + "settings_lightroom_connect_title": "Lightroom verbinden", + "settings_lightroom_connected": "Verbunden als {name}", + "settings_lightroom_disconnect": "Trennen", + "settings_lightroom_disconnect_confirmBody": "Verknüpfte Fotos bleiben bei deinen Tauchgängen und werden weiterhin aus dem Medienspeicher angezeigt. Neue Fotos werden nicht mehr zugeordnet.", + "settings_lightroom_disconnect_confirmTitle": "Lightroom trennen?", + "settings_lightroom_lastPoll": "Zuletzt geprüft: {when}", + "settings_lightroom_needsReauth": "Erneute Verbindung erforderlich", + "settings_lightroom_scanNow": "Lightroom durchsuchen", + "settings_lightroom_scan_running": "Lightroom wird durchsucht...", + "settings_lightroom_scan_summary": "{attached} verknüpft, {suggested} vorgeschlagen, {skipped} bereits verknüpft", + "settings_lightroom_subtitle": "Fotos und Videos automatisch mit Tauchgängen verknüpfen", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Checklistenvorlagen", "settings_manage_checklistTemplates_subtitle": "Wiederverwendbare Aufgabenlisten für die Reiseplanung", "settings_manage_diveRoles": "Tauchrollen", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 284eb3032..cebbb6d25 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5606,6 +5606,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{Imported 1 photo} other{Imported {imported} photos}} ({skipped} already linked)", "media_import_importedPhotos": "Imported {count} {count, plural, =1{photo} other{photos}}", "media_import_importingPhotos": "Importing {count} {count, plural, =1{photo} other{photos}}...", + "media_lightroom_openInLightroom": "Open in Lightroom", + "media_lightroom_suggestion_accept": "Add to this dive", + "media_lightroom_suggestion_dismiss": "Dismiss", + "media_lightroom_suggestions_title": "Suggested from Lightroom", "media_miniProfile_headerLabel": "Dive Profile", "media_miniProfile_semanticLabel": "Mini dive profile chart", "media_photoPicker_appBarTitle": "Select Photos", @@ -6722,6 +6726,72 @@ "settings_language_appBar_title": "Language", "settings_language_selected": "Selected", "settings_language_systemDefault": "System Default", + "settings_lightroom_albumFilter_all": "Entire catalog", + "settings_lightroom_albumFilter_title": "Albums to scan", + "settings_lightroom_autoPoll_title": "Check for new photos automatically", + "settings_lightroom_clientId_help": "Create an integration in the Adobe Developer Console with the Lightroom Services API and a credential type that supports PKCE. Set the redirect URI to {redirectUri}.", + "@settings_lightroom_clientId_help": { + "placeholders": { + "redirectUri": { + "type": "String" + } + } + }, + "settings_lightroom_clientId_label": "Adobe client ID", + "settings_lightroom_clientSecret_label": "Client secret (optional)", + "settings_lightroom_connect": "Connect Lightroom", + "settings_lightroom_connect_codeLabel": "Redirected URL or code", + "settings_lightroom_connect_emptyCode": "Paste the redirected URL or authorization code", + "settings_lightroom_connect_failed": "Could not connect to Lightroom: {error}", + "@settings_lightroom_connect_failed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "settings_lightroom_connect_instructions": "Sign in to Adobe in the browser window, then paste the full address of the page you land on (it contains the authorization code).", + "settings_lightroom_connect_reopenBrowser": "Reopen browser", + "settings_lightroom_connect_submit": "Connect", + "settings_lightroom_connect_title": "Connect Lightroom", + "settings_lightroom_connected": "Connected as {name}", + "@settings_lightroom_connected": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "settings_lightroom_disconnect": "Disconnect", + "settings_lightroom_disconnect_confirmBody": "Linked photos stay on your dives and keep displaying from the media store. New photos will no longer be matched.", + "settings_lightroom_disconnect_confirmTitle": "Disconnect Lightroom?", + "settings_lightroom_lastPoll": "Last checked: {when}", + "@settings_lightroom_lastPoll": { + "placeholders": { + "when": { + "type": "String" + } + } + }, + "settings_lightroom_needsReauth": "Reconnect needed", + "settings_lightroom_scanNow": "Scan Lightroom", + "settings_lightroom_scan_running": "Scanning Lightroom...", + "settings_lightroom_scan_summary": "{attached} linked, {suggested} suggested, {skipped} already linked", + "@settings_lightroom_scan_summary": { + "placeholders": { + "attached": { + "type": "int" + }, + "suggested": { + "type": "int" + }, + "skipped": { + "type": "int" + } + } + }, + "settings_lightroom_subtitle": "Auto-link photos and videos to dives", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Checklist Templates", "settings_manage_checklistTemplates_subtitle": "Reusable to-do lists for trip planning", "settings_manage_diveRoles": "Dive Roles", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index fae8c1761..59fecbc5a 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3259,6 +3259,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{Se importo 1 foto} other{Se importaron {imported} fotos}} ({skipped} ya vinculadas)", "media_import_importedPhotos": "Se importaron {count} {count, plural, =1{foto} other{fotos}}", "media_import_importingPhotos": "Importando {count} {count, plural, =1{foto} other{fotos}}...", + "media_lightroom_openInLightroom": "Abrir en Lightroom", + "media_lightroom_suggestion_accept": "Añadir a esta inmersión", + "media_lightroom_suggestion_dismiss": "Descartar", + "media_lightroom_suggestions_title": "Sugerencias de Lightroom", "media_miniProfile_headerLabel": "Perfil de inmersion", "media_miniProfile_semanticLabel": "Grafico del perfil de inmersion en miniatura", "media_photoPicker_appBarTitle": "Seleccionar fotos", @@ -3767,6 +3771,31 @@ "settings_language_appBar_title": "Idioma", "settings_language_selected": "Seleccionado", "settings_language_systemDefault": "Predeterminado del sistema", + "settings_lightroom_albumFilter_all": "Catálogo completo", + "settings_lightroom_albumFilter_title": "Álbumes a escanear", + "settings_lightroom_autoPoll_title": "Buscar fotos nuevas automáticamente", + "settings_lightroom_clientId_help": "Crea una integración en la Adobe Developer Console con la API de Lightroom Services y un tipo de credencial compatible con PKCE. Establece la URI de redirección en {redirectUri}.", + "settings_lightroom_clientId_label": "ID de cliente de Adobe", + "settings_lightroom_clientSecret_label": "Secreto de cliente (opcional)", + "settings_lightroom_connect": "Conectar Lightroom", + "settings_lightroom_connect_codeLabel": "URL redirigida o código", + "settings_lightroom_connect_emptyCode": "Pega la URL redirigida o el código de autorización", + "settings_lightroom_connect_failed": "No se pudo conectar con Lightroom: {error}", + "settings_lightroom_connect_instructions": "Inicia sesión en Adobe en la ventana del navegador y pega la dirección completa de la página a la que llegas (contiene el código de autorización).", + "settings_lightroom_connect_reopenBrowser": "Reabrir navegador", + "settings_lightroom_connect_submit": "Conectar", + "settings_lightroom_connect_title": "Conectar Lightroom", + "settings_lightroom_connected": "Conectado como {name}", + "settings_lightroom_disconnect": "Desconectar", + "settings_lightroom_disconnect_confirmBody": "Las fotos vinculadas permanecen en tus inmersiones y se siguen mostrando desde el almacén de medios. Las fotos nuevas ya no se vincularán.", + "settings_lightroom_disconnect_confirmTitle": "¿Desconectar Lightroom?", + "settings_lightroom_lastPoll": "Última comprobación: {when}", + "settings_lightroom_needsReauth": "Reconexión necesaria", + "settings_lightroom_scanNow": "Escanear Lightroom", + "settings_lightroom_scan_running": "Escaneando Lightroom...", + "settings_lightroom_scan_summary": "{attached} vinculadas, {suggested} sugeridas, {skipped} ya vinculadas", + "settings_lightroom_subtitle": "Vincular automáticamente fotos y vídeos a inmersiones", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Plantillas de listas de verificación", "settings_manage_checklistTemplates_subtitle": "Listas de tareas reutilizables para planificar viajes", "settings_manage_diveRoles": "Roles de buceo", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 8ae48641b..157de6822 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3225,6 +3225,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{1 photo importee} other{{imported} photos importees}} ({skipped} deja associees)", "media_import_importedPhotos": "{count} {count, plural, =1{photo importee} other{photos importees}}", "media_import_importingPhotos": "Importation de {count} {count, plural, =1{photo} other{photos}}...", + "media_lightroom_openInLightroom": "Ouvrir dans Lightroom", + "media_lightroom_suggestion_accept": "Ajouter à cette plongée", + "media_lightroom_suggestion_dismiss": "Ignorer", + "media_lightroom_suggestions_title": "Suggestions de Lightroom", "media_miniProfile_headerLabel": "Profil de plongee", "media_miniProfile_semanticLabel": "Mini graphique du profil de plongee", "media_photoPicker_appBarTitle": "Selectionner des photos", @@ -3733,6 +3737,31 @@ "settings_language_appBar_title": "Langue", "settings_language_selected": "Selectionnee", "settings_language_systemDefault": "Defaut du systeme", + "settings_lightroom_albumFilter_all": "Catalogue entier", + "settings_lightroom_albumFilter_title": "Albums à analyser", + "settings_lightroom_autoPoll_title": "Rechercher automatiquement les nouvelles photos", + "settings_lightroom_clientId_help": "Créez une intégration dans l'Adobe Developer Console avec l'API Lightroom Services et un type d'identifiant compatible PKCE. Définissez l'URI de redirection sur {redirectUri}.", + "settings_lightroom_clientId_label": "ID client Adobe", + "settings_lightroom_clientSecret_label": "Secret client (facultatif)", + "settings_lightroom_connect": "Connecter Lightroom", + "settings_lightroom_connect_codeLabel": "URL redirigée ou code", + "settings_lightroom_connect_emptyCode": "Collez l'URL redirigée ou le code d'autorisation", + "settings_lightroom_connect_failed": "Impossible de se connecter à Lightroom : {error}", + "settings_lightroom_connect_instructions": "Connectez-vous à Adobe dans la fenêtre du navigateur, puis collez l'adresse complète de la page sur laquelle vous arrivez (elle contient le code d'autorisation).", + "settings_lightroom_connect_reopenBrowser": "Rouvrir le navigateur", + "settings_lightroom_connect_submit": "Connecter", + "settings_lightroom_connect_title": "Connecter Lightroom", + "settings_lightroom_connected": "Connecté en tant que {name}", + "settings_lightroom_disconnect": "Déconnecter", + "settings_lightroom_disconnect_confirmBody": "Les photos liées restent sur vos plongées et continuent de s'afficher depuis le stockage multimédia. Les nouvelles photos ne seront plus associées.", + "settings_lightroom_disconnect_confirmTitle": "Déconnecter Lightroom ?", + "settings_lightroom_lastPoll": "Dernière vérification : {when}", + "settings_lightroom_needsReauth": "Reconnexion requise", + "settings_lightroom_scanNow": "Analyser Lightroom", + "settings_lightroom_scan_running": "Analyse de Lightroom...", + "settings_lightroom_scan_summary": "{attached} liées, {suggested} suggérées, {skipped} déjà liées", + "settings_lightroom_subtitle": "Associer automatiquement photos et vidéos aux plongées", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Modèles de liste de contrôle", "settings_manage_checklistTemplates_subtitle": "Listes de tâches réutilisables pour la planification de voyages", "settings_manage_diveRoles": "Rôles de plongée", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index ef8a84478..999a323a3 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3225,6 +3225,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{יובאה תמונה אחת} other{יובאו {imported} תמונות}} ({skipped} כבר מקושרות)", "media_import_importedPhotos": "יובאו {count} {count, plural, =1{תמונה} other{תמונות}}", "media_import_importingPhotos": "מייבא {count} {count, plural, =1{תמונה} other{תמונות}}...", + "media_lightroom_openInLightroom": "פתיחה ב-Lightroom", + "media_lightroom_suggestion_accept": "הוספה לצלילה זו", + "media_lightroom_suggestion_dismiss": "התעלמות", + "media_lightroom_suggestions_title": "הצעות מ-Lightroom", "media_miniProfile_headerLabel": "פרופיל צלילה", "media_miniProfile_semanticLabel": "תרשים מיני של פרופיל צלילה", "media_photoPicker_appBarTitle": "בחר תמונות", @@ -3767,6 +3771,31 @@ "settings_language_appBar_title": "שפה", "settings_language_selected": "נבחר", "settings_language_systemDefault": "ברירת מחדל של המערכת", + "settings_lightroom_albumFilter_all": "כל הקטלוג", + "settings_lightroom_albumFilter_title": "אלבומים לסריקה", + "settings_lightroom_autoPoll_title": "בדיקה אוטומטית של תמונות חדשות", + "settings_lightroom_clientId_help": "צרו אינטגרציה ב-Adobe Developer Console עם Lightroom Services API וסוג אישור התומך ב-PKCE. הגדירו את כתובת ההפניה ל-{redirectUri}.", + "settings_lightroom_clientId_label": "מזהה לקוח של Adobe", + "settings_lightroom_clientSecret_label": "סוד לקוח (אופציונלי)", + "settings_lightroom_connect": "חיבור Lightroom", + "settings_lightroom_connect_codeLabel": "כתובת URL מופנית או קוד", + "settings_lightroom_connect_emptyCode": "הדביקו את כתובת ה-URL המופנית או את קוד ההרשאה", + "settings_lightroom_connect_failed": "לא ניתן להתחבר ל-Lightroom: {error}", + "settings_lightroom_connect_instructions": "התחברו ל-Adobe בחלון הדפדפן, ואז הדביקו את הכתובת המלאה של הדף שאליו הגעתם (היא מכילה את קוד ההרשאה).", + "settings_lightroom_connect_reopenBrowser": "פתיחת הדפדפן מחדש", + "settings_lightroom_connect_submit": "חיבור", + "settings_lightroom_connect_title": "חיבור Lightroom", + "settings_lightroom_connected": "מחובר בתור {name}", + "settings_lightroom_disconnect": "ניתוק", + "settings_lightroom_disconnect_confirmBody": "תמונות מקושרות נשארות בצלילות שלכם וממשיכות להיות מוצגות מאחסון המדיה. תמונות חדשות לא יותאמו יותר.", + "settings_lightroom_disconnect_confirmTitle": "לנתק את Lightroom?", + "settings_lightroom_lastPoll": "בדיקה אחרונה: {when}", + "settings_lightroom_needsReauth": "נדרש חיבור מחדש", + "settings_lightroom_scanNow": "סריקת Lightroom", + "settings_lightroom_scan_running": "סורק את Lightroom...", + "settings_lightroom_scan_summary": "{attached} קושרו, {suggested} הוצעו, {skipped} כבר מקושרות", + "settings_lightroom_subtitle": "קישור אוטומטי של תמונות וסרטונים לצלילות", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "תבניות רשימות משימות", "settings_manage_checklistTemplates_subtitle": "רשימות משימות לשימוש חוזר לתכנון טיולים", "settings_manage_diveRoles": "תפקידי צלילה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index a3bfb4716..053becdf1 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3225,6 +3225,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{1 fotó importálva} other{{imported} fotó importálva}} ({skipped} már hozzákapcsolva)", "media_import_importedPhotos": "{count} {count, plural, =1{foto} other{foto}} importalva", "media_import_importingPhotos": "{count} {count, plural, =1{foto} other{foto}} importalasa...", + "media_lightroom_openInLightroom": "Megnyitás a Lightroomban", + "media_lightroom_suggestion_accept": "Hozzáadás ehhez a merüléshez", + "media_lightroom_suggestion_dismiss": "Elvetés", + "media_lightroom_suggestions_title": "Javaslatok a Lightroomból", "media_miniProfile_headerLabel": "Merülesi profil", "media_miniProfile_semanticLabel": "Mini merülesi profil diagram", "media_photoPicker_appBarTitle": "Fotok kivalasztasa", @@ -3733,6 +3737,31 @@ "settings_language_appBar_title": "Nyelv", "settings_language_selected": "Kivalasztva", "settings_language_systemDefault": "Rendszer alapertelmezett", + "settings_lightroom_albumFilter_all": "Teljes katalógus", + "settings_lightroom_albumFilter_title": "Vizsgálandó albumok", + "settings_lightroom_autoPoll_title": "Új fotók automatikus keresése", + "settings_lightroom_clientId_help": "Hozz létre egy integrációt az Adobe Developer Console-ban a Lightroom Services API-val és egy PKCE-t támogató hitelesítőtípussal. Az átirányítási URI legyen {redirectUri}.", + "settings_lightroom_clientId_label": "Adobe kliensazonosító", + "settings_lightroom_clientSecret_label": "Klienstitok (nem kötelező)", + "settings_lightroom_connect": "Lightroom csatlakoztatása", + "settings_lightroom_connect_codeLabel": "Átirányított URL vagy kód", + "settings_lightroom_connect_emptyCode": "Illeszd be az átirányított URL-t vagy az engedélyezési kódot", + "settings_lightroom_connect_failed": "Nem sikerült csatlakozni a Lightroomhoz: {error}", + "settings_lightroom_connect_instructions": "Jelentkezz be az Adobe-fiókodba a böngészőablakban, majd illeszd be annak az oldalnak a teljes címét, ahová érkezel (ez tartalmazza az engedélyezési kódot).", + "settings_lightroom_connect_reopenBrowser": "Böngésző újranyitása", + "settings_lightroom_connect_submit": "Csatlakozás", + "settings_lightroom_connect_title": "Lightroom csatlakoztatása", + "settings_lightroom_connected": "Csatlakozva mint {name}", + "settings_lightroom_disconnect": "Leválasztás", + "settings_lightroom_disconnect_confirmBody": "A összekapcsolt fotók a merüléseidnél maradnak, és továbbra is a médiatárolóból jelennek meg. Az új fotók már nem lesznek párosítva.", + "settings_lightroom_disconnect_confirmTitle": "Leválasztod a Lightroomot?", + "settings_lightroom_lastPoll": "Utolsó ellenőrzés: {when}", + "settings_lightroom_needsReauth": "Újracsatlakozás szükséges", + "settings_lightroom_scanNow": "Lightroom átvizsgálása", + "settings_lightroom_scan_running": "Lightroom vizsgálata...", + "settings_lightroom_scan_summary": "{attached} összekapcsolva, {suggested} javasolva, {skipped} már összekapcsolva", + "settings_lightroom_subtitle": "Fotók és videók automatikus hozzárendelése a merülésekhez", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Ellenőrzőlista-sablonok", "settings_manage_checklistTemplates_subtitle": "Újrafelhasználható tennivalólisták az utak tervezéséhez", "settings_manage_diveRoles": "Merülési szerepek", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 0798d31f3..0d8459e85 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3225,6 +3225,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{Importata 1 foto} other{Importate {imported} foto}} ({skipped} gia collegate)", "media_import_importedPhotos": "{count, plural, =1{Importata {count} foto} other{Importate {count} foto}}", "media_import_importingPhotos": "Importazione di {count} {count, plural, =1{foto} other{foto}} in corso...", + "media_lightroom_openInLightroom": "Apri in Lightroom", + "media_lightroom_suggestion_accept": "Aggiungi a questa immersione", + "media_lightroom_suggestion_dismiss": "Ignora", + "media_lightroom_suggestions_title": "Suggerimenti da Lightroom", "media_miniProfile_headerLabel": "Profilo immersione", "media_miniProfile_semanticLabel": "Grafico mini profilo immersione", "media_photoPicker_appBarTitle": "Seleziona foto", @@ -3729,6 +3733,31 @@ "settings_language_appBar_title": "Lingua", "settings_language_selected": "Selezionata", "settings_language_systemDefault": "Predefinito di sistema", + "settings_lightroom_albumFilter_all": "Intero catalogo", + "settings_lightroom_albumFilter_title": "Album da scansionare", + "settings_lightroom_autoPoll_title": "Cerca automaticamente nuove foto", + "settings_lightroom_clientId_help": "Crea un'integrazione nella Adobe Developer Console con l'API Lightroom Services e un tipo di credenziale che supporti PKCE. Imposta l'URI di reindirizzamento su {redirectUri}.", + "settings_lightroom_clientId_label": "ID client Adobe", + "settings_lightroom_clientSecret_label": "Segreto client (facoltativo)", + "settings_lightroom_connect": "Collega Lightroom", + "settings_lightroom_connect_codeLabel": "URL reindirizzato o codice", + "settings_lightroom_connect_emptyCode": "Incolla l'URL reindirizzato o il codice di autorizzazione", + "settings_lightroom_connect_failed": "Impossibile connettersi a Lightroom: {error}", + "settings_lightroom_connect_instructions": "Accedi ad Adobe nella finestra del browser, poi incolla l'indirizzo completo della pagina su cui arrivi (contiene il codice di autorizzazione).", + "settings_lightroom_connect_reopenBrowser": "Riapri il browser", + "settings_lightroom_connect_submit": "Collega", + "settings_lightroom_connect_title": "Collega Lightroom", + "settings_lightroom_connected": "Collegato come {name}", + "settings_lightroom_disconnect": "Disconnetti", + "settings_lightroom_disconnect_confirmBody": "Le foto collegate restano nelle tue immersioni e continuano a essere mostrate dall'archivio multimediale. Le nuove foto non verranno più abbinate.", + "settings_lightroom_disconnect_confirmTitle": "Disconnettere Lightroom?", + "settings_lightroom_lastPoll": "Ultimo controllo: {when}", + "settings_lightroom_needsReauth": "Riconnessione necessaria", + "settings_lightroom_scanNow": "Scansiona Lightroom", + "settings_lightroom_scan_running": "Scansione di Lightroom...", + "settings_lightroom_scan_summary": "{attached} collegate, {suggested} suggerite, {skipped} già collegate", + "settings_lightroom_subtitle": "Collega automaticamente foto e video alle immersioni", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Modelli di liste di controllo", "settings_manage_checklistTemplates_subtitle": "Liste di controllo riutilizzabili per pianificare i viaggi", "settings_manage_diveRoles": "Ruoli di immersione", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index e8d6fb7a3..dd53b582e 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -17463,6 +17463,30 @@ abstract class AppLocalizations { /// **'Importing {count} {count, plural, =1{photo} other{photos}}...'** String media_import_importingPhotos(int count); + /// No description provided for @media_lightroom_openInLightroom. + /// + /// In en, this message translates to: + /// **'Open in Lightroom'** + String get media_lightroom_openInLightroom; + + /// No description provided for @media_lightroom_suggestion_accept. + /// + /// In en, this message translates to: + /// **'Add to this dive'** + String get media_lightroom_suggestion_accept; + + /// No description provided for @media_lightroom_suggestion_dismiss. + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get media_lightroom_suggestion_dismiss; + + /// No description provided for @media_lightroom_suggestions_title. + /// + /// In en, this message translates to: + /// **'Suggested from Lightroom'** + String get media_lightroom_suggestions_title; + /// No description provided for @media_miniProfile_headerLabel. /// /// In en, this message translates to: @@ -20875,6 +20899,160 @@ abstract class AppLocalizations { /// **'System Default'** String get settings_language_systemDefault; + /// No description provided for @settings_lightroom_albumFilter_all. + /// + /// In en, this message translates to: + /// **'Entire catalog'** + String get settings_lightroom_albumFilter_all; + + /// No description provided for @settings_lightroom_albumFilter_title. + /// + /// In en, this message translates to: + /// **'Albums to scan'** + String get settings_lightroom_albumFilter_title; + + /// No description provided for @settings_lightroom_autoPoll_title. + /// + /// In en, this message translates to: + /// **'Check for new photos automatically'** + String get settings_lightroom_autoPoll_title; + + /// No description provided for @settings_lightroom_clientId_help. + /// + /// In en, this message translates to: + /// **'Create an integration in the Adobe Developer Console with the Lightroom Services API and a credential type that supports PKCE. Set the redirect URI to {redirectUri}.'** + String settings_lightroom_clientId_help(String redirectUri); + + /// No description provided for @settings_lightroom_clientId_label. + /// + /// In en, this message translates to: + /// **'Adobe client ID'** + String get settings_lightroom_clientId_label; + + /// No description provided for @settings_lightroom_clientSecret_label. + /// + /// In en, this message translates to: + /// **'Client secret (optional)'** + String get settings_lightroom_clientSecret_label; + + /// No description provided for @settings_lightroom_connect. + /// + /// In en, this message translates to: + /// **'Connect Lightroom'** + String get settings_lightroom_connect; + + /// No description provided for @settings_lightroom_connect_codeLabel. + /// + /// In en, this message translates to: + /// **'Redirected URL or code'** + String get settings_lightroom_connect_codeLabel; + + /// No description provided for @settings_lightroom_connect_emptyCode. + /// + /// In en, this message translates to: + /// **'Paste the redirected URL or authorization code'** + String get settings_lightroom_connect_emptyCode; + + /// No description provided for @settings_lightroom_connect_failed. + /// + /// In en, this message translates to: + /// **'Could not connect to Lightroom: {error}'** + String settings_lightroom_connect_failed(String error); + + /// No description provided for @settings_lightroom_connect_instructions. + /// + /// In en, this message translates to: + /// **'Sign in to Adobe in the browser window, then paste the full address of the page you land on (it contains the authorization code).'** + String get settings_lightroom_connect_instructions; + + /// No description provided for @settings_lightroom_connect_reopenBrowser. + /// + /// In en, this message translates to: + /// **'Reopen browser'** + String get settings_lightroom_connect_reopenBrowser; + + /// No description provided for @settings_lightroom_connect_submit. + /// + /// In en, this message translates to: + /// **'Connect'** + String get settings_lightroom_connect_submit; + + /// No description provided for @settings_lightroom_connect_title. + /// + /// In en, this message translates to: + /// **'Connect Lightroom'** + String get settings_lightroom_connect_title; + + /// No description provided for @settings_lightroom_connected. + /// + /// In en, this message translates to: + /// **'Connected as {name}'** + String settings_lightroom_connected(String name); + + /// No description provided for @settings_lightroom_disconnect. + /// + /// In en, this message translates to: + /// **'Disconnect'** + String get settings_lightroom_disconnect; + + /// No description provided for @settings_lightroom_disconnect_confirmBody. + /// + /// In en, this message translates to: + /// **'Linked photos stay on your dives and keep displaying from the media store. New photos will no longer be matched.'** + String get settings_lightroom_disconnect_confirmBody; + + /// No description provided for @settings_lightroom_disconnect_confirmTitle. + /// + /// In en, this message translates to: + /// **'Disconnect Lightroom?'** + String get settings_lightroom_disconnect_confirmTitle; + + /// No description provided for @settings_lightroom_lastPoll. + /// + /// In en, this message translates to: + /// **'Last checked: {when}'** + String settings_lightroom_lastPoll(String when); + + /// No description provided for @settings_lightroom_needsReauth. + /// + /// In en, this message translates to: + /// **'Reconnect needed'** + String get settings_lightroom_needsReauth; + + /// No description provided for @settings_lightroom_scanNow. + /// + /// In en, this message translates to: + /// **'Scan Lightroom'** + String get settings_lightroom_scanNow; + + /// No description provided for @settings_lightroom_scan_running. + /// + /// In en, this message translates to: + /// **'Scanning Lightroom...'** + String get settings_lightroom_scan_running; + + /// No description provided for @settings_lightroom_scan_summary. + /// + /// In en, this message translates to: + /// **'{attached} linked, {suggested} suggested, {skipped} already linked'** + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ); + + /// No description provided for @settings_lightroom_subtitle. + /// + /// In en, this message translates to: + /// **'Auto-link photos and videos to dives'** + String get settings_lightroom_subtitle; + + /// No description provided for @settings_lightroom_title. + /// + /// In en, this message translates to: + /// **'Adobe Lightroom'** + String get settings_lightroom_title; + /// No description provided for @settings_manage_checklistTemplates. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index c7a0e4fc4..13eddce31 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10048,6 +10048,18 @@ class AppLocalizationsAr extends AppLocalizations { return 'جارٍ استيراد $count $_temp0...'; } + @override + String get media_lightroom_openInLightroom => 'فتح في Lightroom'; + + @override + String get media_lightroom_suggestion_accept => 'إضافة إلى هذه الغطسة'; + + @override + String get media_lightroom_suggestion_dismiss => 'تجاهل'; + + @override + String get media_lightroom_suggestions_title => 'اقتراحات من Lightroom'; + @override String get media_miniProfile_headerLabel => 'ملف الغوصة'; @@ -12096,6 +12108,102 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_language_systemDefault => 'الافتراضي للنظام'; + @override + String get settings_lightroom_albumFilter_all => 'الكتالوج بأكمله'; + + @override + String get settings_lightroom_albumFilter_title => 'الألبومات المراد فحصها'; + + @override + String get settings_lightroom_autoPoll_title => + 'التحقق من الصور الجديدة تلقائيًا'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'أنشئ تكاملًا في Adobe Developer Console باستخدام واجهة Lightroom Services API ونوع اعتماد يدعم PKCE. عيّن عنوان إعادة التوجيه إلى $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'معرّف عميل Adobe'; + + @override + String get settings_lightroom_clientSecret_label => 'سر العميل (اختياري)'; + + @override + String get settings_lightroom_connect => 'ربط Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => + 'عنوان URL المُعاد توجيهه أو الرمز'; + + @override + String get settings_lightroom_connect_emptyCode => + 'الصق عنوان URL المُعاد توجيهه أو رمز التفويض'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'تعذّر الاتصال بـ Lightroom: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'سجّل الدخول إلى Adobe في نافذة المتصفح، ثم الصق العنوان الكامل للصفحة التي تصل إليها (فهو يحتوي على رمز التفويض).'; + + @override + String get settings_lightroom_connect_reopenBrowser => 'إعادة فتح المتصفح'; + + @override + String get settings_lightroom_connect_submit => 'ربط'; + + @override + String get settings_lightroom_connect_title => 'ربط Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return 'متصل باسم $name'; + } + + @override + String get settings_lightroom_disconnect => 'قطع الاتصال'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'تبقى الصور المرتبطة في غطساتك وتستمر في الظهور من مخزن الوسائط. لن تتم مطابقة الصور الجديدة بعد الآن.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + 'قطع الاتصال بـ Lightroom؟'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'آخر فحص: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'يلزم إعادة الاتصال'; + + @override + String get settings_lightroom_scanNow => 'فحص Lightroom'; + + @override + String get settings_lightroom_scan_running => 'جارٍ فحص Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached مرتبطة، $suggested مقترحة، $skipped مرتبطة بالفعل'; + } + + @override + String get settings_lightroom_subtitle => + 'ربط الصور ومقاطع الفيديو بالغطسات تلقائيًا'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'قوالب قوائم التحقق'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 4a407713e..70e2dd729 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10240,6 +10240,19 @@ class AppLocalizationsDe extends AppLocalizations { return '$count $_temp0 werden importiert...'; } + @override + String get media_lightroom_openInLightroom => 'In Lightroom öffnen'; + + @override + String get media_lightroom_suggestion_accept => + 'Zu diesem Tauchgang hinzufügen'; + + @override + String get media_lightroom_suggestion_dismiss => 'Verwerfen'; + + @override + String get media_lightroom_suggestions_title => 'Vorschläge aus Lightroom'; + @override String get media_miniProfile_headerLabel => 'Tauchprofil'; @@ -12320,6 +12333,104 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_language_systemDefault => 'Systemstandard'; + @override + String get settings_lightroom_albumFilter_all => 'Gesamter Katalog'; + + @override + String get settings_lightroom_albumFilter_title => 'Zu durchsuchende Alben'; + + @override + String get settings_lightroom_autoPoll_title => + 'Automatisch nach neuen Fotos suchen'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Erstelle in der Adobe Developer Console eine Integration mit der Lightroom Services API und einem Anmeldetyp, der PKCE unterstützt. Lege als Redirect-URI $redirectUri fest.'; + } + + @override + String get settings_lightroom_clientId_label => 'Adobe Client-ID'; + + @override + String get settings_lightroom_clientSecret_label => + 'Client-Secret (optional)'; + + @override + String get settings_lightroom_connect => 'Lightroom verbinden'; + + @override + String get settings_lightroom_connect_codeLabel => + 'Weitergeleitete URL oder Code'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Weitergeleitete URL oder Autorisierungscode einfügen'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'Verbindung zu Lightroom fehlgeschlagen: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Melde dich im Browserfenster bei Adobe an und füge dann die vollständige Adresse der Seite ein, auf der du landest (sie enthält den Autorisierungscode).'; + + @override + String get settings_lightroom_connect_reopenBrowser => + 'Browser erneut öffnen'; + + @override + String get settings_lightroom_connect_submit => 'Verbinden'; + + @override + String get settings_lightroom_connect_title => 'Lightroom verbinden'; + + @override + String settings_lightroom_connected(String name) { + return 'Verbunden als $name'; + } + + @override + String get settings_lightroom_disconnect => 'Trennen'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'Verknüpfte Fotos bleiben bei deinen Tauchgängen und werden weiterhin aus dem Medienspeicher angezeigt. Neue Fotos werden nicht mehr zugeordnet.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => 'Lightroom trennen?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Zuletzt geprüft: $when'; + } + + @override + String get settings_lightroom_needsReauth => + 'Erneute Verbindung erforderlich'; + + @override + String get settings_lightroom_scanNow => 'Lightroom durchsuchen'; + + @override + String get settings_lightroom_scan_running => 'Lightroom wird durchsucht...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached verknüpft, $suggested vorgeschlagen, $skipped bereits verknüpft'; + } + + @override + String get settings_lightroom_subtitle => + 'Fotos und Videos automatisch mit Tauchgängen verknüpfen'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Checklistenvorlagen'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 1cd6f7dd2..96768cb70 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10080,6 +10080,18 @@ class AppLocalizationsEn extends AppLocalizations { return 'Importing $count $_temp0...'; } + @override + String get media_lightroom_openInLightroom => 'Open in Lightroom'; + + @override + String get media_lightroom_suggestion_accept => 'Add to this dive'; + + @override + String get media_lightroom_suggestion_dismiss => 'Dismiss'; + + @override + String get media_lightroom_suggestions_title => 'Suggested from Lightroom'; + @override String get media_miniProfile_headerLabel => 'Dive Profile'; @@ -12123,6 +12135,102 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_language_systemDefault => 'System Default'; + @override + String get settings_lightroom_albumFilter_all => 'Entire catalog'; + + @override + String get settings_lightroom_albumFilter_title => 'Albums to scan'; + + @override + String get settings_lightroom_autoPoll_title => + 'Check for new photos automatically'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Create an integration in the Adobe Developer Console with the Lightroom Services API and a credential type that supports PKCE. Set the redirect URI to $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'Adobe client ID'; + + @override + String get settings_lightroom_clientSecret_label => + 'Client secret (optional)'; + + @override + String get settings_lightroom_connect => 'Connect Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => 'Redirected URL or code'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Paste the redirected URL or authorization code'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'Could not connect to Lightroom: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Sign in to Adobe in the browser window, then paste the full address of the page you land on (it contains the authorization code).'; + + @override + String get settings_lightroom_connect_reopenBrowser => 'Reopen browser'; + + @override + String get settings_lightroom_connect_submit => 'Connect'; + + @override + String get settings_lightroom_connect_title => 'Connect Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return 'Connected as $name'; + } + + @override + String get settings_lightroom_disconnect => 'Disconnect'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'Linked photos stay on your dives and keep displaying from the media store. New photos will no longer be matched.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + 'Disconnect Lightroom?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Last checked: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'Reconnect needed'; + + @override + String get settings_lightroom_scanNow => 'Scan Lightroom'; + + @override + String get settings_lightroom_scan_running => 'Scanning Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached linked, $suggested suggested, $skipped already linked'; + } + + @override + String get settings_lightroom_subtitle => + 'Auto-link photos and videos to dives'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Checklist Templates'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 12b9d217e..bdf5a9f21 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10223,6 +10223,18 @@ class AppLocalizationsEs extends AppLocalizations { return 'Importando $count $_temp0...'; } + @override + String get media_lightroom_openInLightroom => 'Abrir en Lightroom'; + + @override + String get media_lightroom_suggestion_accept => 'Añadir a esta inmersión'; + + @override + String get media_lightroom_suggestion_dismiss => 'Descartar'; + + @override + String get media_lightroom_suggestions_title => 'Sugerencias de Lightroom'; + @override String get media_miniProfile_headerLabel => 'Perfil de inmersion'; @@ -12320,6 +12332,102 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_language_systemDefault => 'Predeterminado del sistema'; + @override + String get settings_lightroom_albumFilter_all => 'Catálogo completo'; + + @override + String get settings_lightroom_albumFilter_title => 'Álbumes a escanear'; + + @override + String get settings_lightroom_autoPoll_title => + 'Buscar fotos nuevas automáticamente'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Crea una integración en la Adobe Developer Console con la API de Lightroom Services y un tipo de credencial compatible con PKCE. Establece la URI de redirección en $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'ID de cliente de Adobe'; + + @override + String get settings_lightroom_clientSecret_label => + 'Secreto de cliente (opcional)'; + + @override + String get settings_lightroom_connect => 'Conectar Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => 'URL redirigida o código'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Pega la URL redirigida o el código de autorización'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'No se pudo conectar con Lightroom: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Inicia sesión en Adobe en la ventana del navegador y pega la dirección completa de la página a la que llegas (contiene el código de autorización).'; + + @override + String get settings_lightroom_connect_reopenBrowser => 'Reabrir navegador'; + + @override + String get settings_lightroom_connect_submit => 'Conectar'; + + @override + String get settings_lightroom_connect_title => 'Conectar Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return 'Conectado como $name'; + } + + @override + String get settings_lightroom_disconnect => 'Desconectar'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'Las fotos vinculadas permanecen en tus inmersiones y se siguen mostrando desde el almacén de medios. Las fotos nuevas ya no se vincularán.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + '¿Desconectar Lightroom?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Última comprobación: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'Reconexión necesaria'; + + @override + String get settings_lightroom_scanNow => 'Escanear Lightroom'; + + @override + String get settings_lightroom_scan_running => 'Escaneando Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached vinculadas, $suggested sugeridas, $skipped ya vinculadas'; + } + + @override + String get settings_lightroom_subtitle => + 'Vincular automáticamente fotos y vídeos a inmersiones'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Plantillas de listas de verificación'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 58559c1f7..db04e6730 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10273,6 +10273,18 @@ class AppLocalizationsFr extends AppLocalizations { return 'Importation de $count $_temp0...'; } + @override + String get media_lightroom_openInLightroom => 'Ouvrir dans Lightroom'; + + @override + String get media_lightroom_suggestion_accept => 'Ajouter à cette plongée'; + + @override + String get media_lightroom_suggestion_dismiss => 'Ignorer'; + + @override + String get media_lightroom_suggestions_title => 'Suggestions de Lightroom'; + @override String get media_miniProfile_headerLabel => 'Profil de plongee'; @@ -12373,6 +12385,103 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_language_systemDefault => 'Defaut du systeme'; + @override + String get settings_lightroom_albumFilter_all => 'Catalogue entier'; + + @override + String get settings_lightroom_albumFilter_title => 'Albums à analyser'; + + @override + String get settings_lightroom_autoPoll_title => + 'Rechercher automatiquement les nouvelles photos'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Créez une intégration dans l\'Adobe Developer Console avec l\'API Lightroom Services et un type d\'identifiant compatible PKCE. Définissez l\'URI de redirection sur $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'ID client Adobe'; + + @override + String get settings_lightroom_clientSecret_label => + 'Secret client (facultatif)'; + + @override + String get settings_lightroom_connect => 'Connecter Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => 'URL redirigée ou code'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Collez l\'URL redirigée ou le code d\'autorisation'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'Impossible de se connecter à Lightroom : $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Connectez-vous à Adobe dans la fenêtre du navigateur, puis collez l\'adresse complète de la page sur laquelle vous arrivez (elle contient le code d\'autorisation).'; + + @override + String get settings_lightroom_connect_reopenBrowser => + 'Rouvrir le navigateur'; + + @override + String get settings_lightroom_connect_submit => 'Connecter'; + + @override + String get settings_lightroom_connect_title => 'Connecter Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return 'Connecté en tant que $name'; + } + + @override + String get settings_lightroom_disconnect => 'Déconnecter'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'Les photos liées restent sur vos plongées et continuent de s\'afficher depuis le stockage multimédia. Les nouvelles photos ne seront plus associées.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + 'Déconnecter Lightroom ?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Dernière vérification : $when'; + } + + @override + String get settings_lightroom_needsReauth => 'Reconnexion requise'; + + @override + String get settings_lightroom_scanNow => 'Analyser Lightroom'; + + @override + String get settings_lightroom_scan_running => 'Analyse de Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached liées, $suggested suggérées, $skipped déjà liées'; + } + + @override + String get settings_lightroom_subtitle => + 'Associer automatiquement photos et vidéos aux plongées'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Modèles de liste de contrôle'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 893bf8ee5..7e7fdb2e4 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -9981,6 +9981,18 @@ class AppLocalizationsHe extends AppLocalizations { return 'מייבא $count $_temp0...'; } + @override + String get media_lightroom_openInLightroom => 'פתיחה ב-Lightroom'; + + @override + String get media_lightroom_suggestion_accept => 'הוספה לצלילה זו'; + + @override + String get media_lightroom_suggestion_dismiss => 'התעלמות'; + + @override + String get media_lightroom_suggestions_title => 'הצעות מ-Lightroom'; + @override String get media_miniProfile_headerLabel => 'פרופיל צלילה'; @@ -12007,6 +12019,100 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_language_systemDefault => 'ברירת מחדל של המערכת'; + @override + String get settings_lightroom_albumFilter_all => 'כל הקטלוג'; + + @override + String get settings_lightroom_albumFilter_title => 'אלבומים לסריקה'; + + @override + String get settings_lightroom_autoPoll_title => + 'בדיקה אוטומטית של תמונות חדשות'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'צרו אינטגרציה ב-Adobe Developer Console עם Lightroom Services API וסוג אישור התומך ב-PKCE. הגדירו את כתובת ההפניה ל-$redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'מזהה לקוח של Adobe'; + + @override + String get settings_lightroom_clientSecret_label => 'סוד לקוח (אופציונלי)'; + + @override + String get settings_lightroom_connect => 'חיבור Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => 'כתובת URL מופנית או קוד'; + + @override + String get settings_lightroom_connect_emptyCode => + 'הדביקו את כתובת ה-URL המופנית או את קוד ההרשאה'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'לא ניתן להתחבר ל-Lightroom: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'התחברו ל-Adobe בחלון הדפדפן, ואז הדביקו את הכתובת המלאה של הדף שאליו הגעתם (היא מכילה את קוד ההרשאה).'; + + @override + String get settings_lightroom_connect_reopenBrowser => 'פתיחת הדפדפן מחדש'; + + @override + String get settings_lightroom_connect_submit => 'חיבור'; + + @override + String get settings_lightroom_connect_title => 'חיבור Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return 'מחובר בתור $name'; + } + + @override + String get settings_lightroom_disconnect => 'ניתוק'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'תמונות מקושרות נשארות בצלילות שלכם וממשיכות להיות מוצגות מאחסון המדיה. תמונות חדשות לא יותאמו יותר.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => 'לנתק את Lightroom?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'בדיקה אחרונה: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'נדרש חיבור מחדש'; + + @override + String get settings_lightroom_scanNow => 'סריקת Lightroom'; + + @override + String get settings_lightroom_scan_running => 'סורק את Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached קושרו, $suggested הוצעו, $skipped כבר מקושרות'; + } + + @override + String get settings_lightroom_subtitle => + 'קישור אוטומטי של תמונות וסרטונים לצלילות'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'תבניות רשימות משימות'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index f7b737f3d..7e04f3512 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10213,6 +10213,19 @@ class AppLocalizationsHu extends AppLocalizations { return '$count $_temp0 importalasa...'; } + @override + String get media_lightroom_openInLightroom => 'Megnyitás a Lightroomban'; + + @override + String get media_lightroom_suggestion_accept => + 'Hozzáadás ehhez a merüléshez'; + + @override + String get media_lightroom_suggestion_dismiss => 'Elvetés'; + + @override + String get media_lightroom_suggestions_title => 'Javaslatok a Lightroomból'; + @override String get media_miniProfile_headerLabel => 'Merülesi profil'; @@ -12298,6 +12311,103 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_language_systemDefault => 'Rendszer alapertelmezett'; + @override + String get settings_lightroom_albumFilter_all => 'Teljes katalógus'; + + @override + String get settings_lightroom_albumFilter_title => 'Vizsgálandó albumok'; + + @override + String get settings_lightroom_autoPoll_title => + 'Új fotók automatikus keresése'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Hozz létre egy integrációt az Adobe Developer Console-ban a Lightroom Services API-val és egy PKCE-t támogató hitelesítőtípussal. Az átirányítási URI legyen $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'Adobe kliensazonosító'; + + @override + String get settings_lightroom_clientSecret_label => + 'Klienstitok (nem kötelező)'; + + @override + String get settings_lightroom_connect => 'Lightroom csatlakoztatása'; + + @override + String get settings_lightroom_connect_codeLabel => + 'Átirányított URL vagy kód'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Illeszd be az átirányított URL-t vagy az engedélyezési kódot'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'Nem sikerült csatlakozni a Lightroomhoz: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Jelentkezz be az Adobe-fiókodba a böngészőablakban, majd illeszd be annak az oldalnak a teljes címét, ahová érkezel (ez tartalmazza az engedélyezési kódot).'; + + @override + String get settings_lightroom_connect_reopenBrowser => 'Böngésző újranyitása'; + + @override + String get settings_lightroom_connect_submit => 'Csatlakozás'; + + @override + String get settings_lightroom_connect_title => 'Lightroom csatlakoztatása'; + + @override + String settings_lightroom_connected(String name) { + return 'Csatlakozva mint $name'; + } + + @override + String get settings_lightroom_disconnect => 'Leválasztás'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'A összekapcsolt fotók a merüléseidnél maradnak, és továbbra is a médiatárolóból jelennek meg. Az új fotók már nem lesznek párosítva.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + 'Leválasztod a Lightroomot?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Utolsó ellenőrzés: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'Újracsatlakozás szükséges'; + + @override + String get settings_lightroom_scanNow => 'Lightroom átvizsgálása'; + + @override + String get settings_lightroom_scan_running => 'Lightroom vizsgálata...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached összekapcsolva, $suggested javasolva, $skipped már összekapcsolva'; + } + + @override + String get settings_lightroom_subtitle => + 'Fotók és videók automatikus hozzárendelése a merülésekhez'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Ellenőrzőlista-sablonok'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 5471039b6..502a85dd0 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10238,6 +10238,19 @@ class AppLocalizationsIt extends AppLocalizations { return 'Importazione di $count $_temp0 in corso...'; } + @override + String get media_lightroom_openInLightroom => 'Apri in Lightroom'; + + @override + String get media_lightroom_suggestion_accept => + 'Aggiungi a questa immersione'; + + @override + String get media_lightroom_suggestion_dismiss => 'Ignora'; + + @override + String get media_lightroom_suggestions_title => 'Suggerimenti da Lightroom'; + @override String get media_miniProfile_headerLabel => 'Profilo immersione'; @@ -12329,6 +12342,103 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_language_systemDefault => 'Predefinito di sistema'; + @override + String get settings_lightroom_albumFilter_all => 'Intero catalogo'; + + @override + String get settings_lightroom_albumFilter_title => 'Album da scansionare'; + + @override + String get settings_lightroom_autoPoll_title => + 'Cerca automaticamente nuove foto'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Crea un\'integrazione nella Adobe Developer Console con l\'API Lightroom Services e un tipo di credenziale che supporti PKCE. Imposta l\'URI di reindirizzamento su $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'ID client Adobe'; + + @override + String get settings_lightroom_clientSecret_label => + 'Segreto client (facoltativo)'; + + @override + String get settings_lightroom_connect => 'Collega Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => + 'URL reindirizzato o codice'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Incolla l\'URL reindirizzato o il codice di autorizzazione'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'Impossibile connettersi a Lightroom: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Accedi ad Adobe nella finestra del browser, poi incolla l\'indirizzo completo della pagina su cui arrivi (contiene il codice di autorizzazione).'; + + @override + String get settings_lightroom_connect_reopenBrowser => 'Riapri il browser'; + + @override + String get settings_lightroom_connect_submit => 'Collega'; + + @override + String get settings_lightroom_connect_title => 'Collega Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return 'Collegato come $name'; + } + + @override + String get settings_lightroom_disconnect => 'Disconnetti'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'Le foto collegate restano nelle tue immersioni e continuano a essere mostrate dall\'archivio multimediale. Le nuove foto non verranno più abbinate.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + 'Disconnettere Lightroom?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Ultimo controllo: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'Riconnessione necessaria'; + + @override + String get settings_lightroom_scanNow => 'Scansiona Lightroom'; + + @override + String get settings_lightroom_scan_running => 'Scansione di Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached collegate, $suggested suggerite, $skipped già collegate'; + } + + @override + String get settings_lightroom_subtitle => + 'Collega automaticamente foto e video alle immersioni'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Modelli di liste di controllo'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 3c584e72d..9dfc4a584 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10167,6 +10167,18 @@ class AppLocalizationsNl extends AppLocalizations { return '$count $_temp0 importeren...'; } + @override + String get media_lightroom_openInLightroom => 'Openen in Lightroom'; + + @override + String get media_lightroom_suggestion_accept => 'Toevoegen aan deze duik'; + + @override + String get media_lightroom_suggestion_dismiss => 'Negeren'; + + @override + String get media_lightroom_suggestions_title => 'Suggesties uit Lightroom'; + @override String get media_miniProfile_headerLabel => 'Duikprofiel'; @@ -12232,6 +12244,104 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_language_systemDefault => 'Systeemstandaard'; + @override + String get settings_lightroom_albumFilter_all => 'Volledige catalogus'; + + @override + String get settings_lightroom_albumFilter_title => 'Te scannen albums'; + + @override + String get settings_lightroom_autoPoll_title => + 'Automatisch controleren op nieuwe foto\'s'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Maak in de Adobe Developer Console een integratie met de Lightroom Services API en een referentietype dat PKCE ondersteunt. Stel de redirect-URI in op $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'Adobe client-ID'; + + @override + String get settings_lightroom_clientSecret_label => + 'Client secret (optioneel)'; + + @override + String get settings_lightroom_connect => 'Lightroom koppelen'; + + @override + String get settings_lightroom_connect_codeLabel => + 'Doorgestuurde URL of code'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Plak de doorgestuurde URL of autorisatiecode'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'Kan geen verbinding maken met Lightroom: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Meld je aan bij Adobe in het browservenster en plak vervolgens het volledige adres van de pagina waarop je terechtkomt (dit bevat de autorisatiecode).'; + + @override + String get settings_lightroom_connect_reopenBrowser => + 'Browser opnieuw openen'; + + @override + String get settings_lightroom_connect_submit => 'Koppelen'; + + @override + String get settings_lightroom_connect_title => 'Lightroom koppelen'; + + @override + String settings_lightroom_connected(String name) { + return 'Gekoppeld als $name'; + } + + @override + String get settings_lightroom_disconnect => 'Ontkoppelen'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'Gekoppelde foto\'s blijven bij je duiken en worden nog steeds weergegeven vanuit de mediaopslag. Nieuwe foto\'s worden niet meer gekoppeld.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + 'Lightroom ontkoppelen?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Laatst gecontroleerd: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'Opnieuw verbinden vereist'; + + @override + String get settings_lightroom_scanNow => 'Lightroom scannen'; + + @override + String get settings_lightroom_scan_running => 'Lightroom wordt gescand...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached gekoppeld, $suggested voorgesteld, $skipped al gekoppeld'; + } + + @override + String get settings_lightroom_subtitle => + 'Foto\'s en video\'s automatisch aan duiken koppelen'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Checklistsjablonen'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index d9fd5cbf4..3ec420ecb 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10245,6 +10245,18 @@ class AppLocalizationsPt extends AppLocalizations { return 'Importando $count $_temp0...'; } + @override + String get media_lightroom_openInLightroom => 'Abrir no Lightroom'; + + @override + String get media_lightroom_suggestion_accept => 'Adicionar a este mergulho'; + + @override + String get media_lightroom_suggestion_dismiss => 'Dispensar'; + + @override + String get media_lightroom_suggestions_title => 'Sugestões do Lightroom'; + @override String get media_miniProfile_headerLabel => 'Perfil do Mergulho'; @@ -12334,6 +12346,103 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_language_systemDefault => 'Padrao do Sistema'; + @override + String get settings_lightroom_albumFilter_all => 'Catálogo inteiro'; + + @override + String get settings_lightroom_albumFilter_title => 'Álbuns a analisar'; + + @override + String get settings_lightroom_autoPoll_title => + 'Verificar novas fotos automaticamente'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return 'Crie uma integração na Adobe Developer Console com a API Lightroom Services e um tipo de credencial compatível com PKCE. Defina a URI de redirecionamento como $redirectUri.'; + } + + @override + String get settings_lightroom_clientId_label => 'ID de cliente da Adobe'; + + @override + String get settings_lightroom_clientSecret_label => + 'Segredo do cliente (opcional)'; + + @override + String get settings_lightroom_connect => 'Conectar Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => + 'URL redirecionada ou código'; + + @override + String get settings_lightroom_connect_emptyCode => + 'Cole a URL redirecionada ou o código de autorização'; + + @override + String settings_lightroom_connect_failed(String error) { + return 'Não foi possível conectar ao Lightroom: $error'; + } + + @override + String get settings_lightroom_connect_instructions => + 'Inicie sessão na Adobe na janela do navegador e cole o endereço completo da página em que você chegar (ele contém o código de autorização).'; + + @override + String get settings_lightroom_connect_reopenBrowser => 'Reabrir navegador'; + + @override + String get settings_lightroom_connect_submit => 'Conectar'; + + @override + String get settings_lightroom_connect_title => 'Conectar Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return 'Conectado como $name'; + } + + @override + String get settings_lightroom_disconnect => 'Desconectar'; + + @override + String get settings_lightroom_disconnect_confirmBody => + 'As fotos vinculadas permanecem nos seus mergulhos e continuam sendo exibidas a partir do armazenamento de mídia. Novas fotos não serão mais vinculadas.'; + + @override + String get settings_lightroom_disconnect_confirmTitle => + 'Desconectar o Lightroom?'; + + @override + String settings_lightroom_lastPoll(String when) { + return 'Última verificação: $when'; + } + + @override + String get settings_lightroom_needsReauth => 'Reconexão necessária'; + + @override + String get settings_lightroom_scanNow => 'Analisar Lightroom'; + + @override + String get settings_lightroom_scan_running => 'Analisando o Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '$attached vinculadas, $suggested sugeridas, $skipped já vinculadas'; + } + + @override + String get settings_lightroom_subtitle => + 'Vincular fotos e vídeos aos mergulhos automaticamente'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => 'Modelos de Lista de Verificação'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index afcae4989..b4f1d32c6 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -9792,6 +9792,18 @@ class AppLocalizationsZh extends AppLocalizations { return '正在导入 $count $_temp0...'; } + @override + String get media_lightroom_openInLightroom => '在 Lightroom 中打开'; + + @override + String get media_lightroom_suggestion_accept => '添加到此潜水'; + + @override + String get media_lightroom_suggestion_dismiss => '忽略'; + + @override + String get media_lightroom_suggestions_title => '来自 Lightroom 的建议'; + @override String get media_miniProfile_headerLabel => '潜水轮廓'; @@ -11742,6 +11754,97 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_language_systemDefault => '系统默认'; + @override + String get settings_lightroom_albumFilter_all => '整个目录'; + + @override + String get settings_lightroom_albumFilter_title => '要扫描的相册'; + + @override + String get settings_lightroom_autoPoll_title => '自动检查新照片'; + + @override + String settings_lightroom_clientId_help(String redirectUri) { + return '在 Adobe Developer Console 中使用 Lightroom Services API 创建集成,并选择支持 PKCE 的凭据类型。将重定向 URI 设置为 $redirectUri。'; + } + + @override + String get settings_lightroom_clientId_label => 'Adobe 客户端 ID'; + + @override + String get settings_lightroom_clientSecret_label => '客户端密钥(可选)'; + + @override + String get settings_lightroom_connect => '连接 Lightroom'; + + @override + String get settings_lightroom_connect_codeLabel => '重定向的网址或代码'; + + @override + String get settings_lightroom_connect_emptyCode => '粘贴重定向的网址或授权码'; + + @override + String settings_lightroom_connect_failed(String error) { + return '无法连接到 Lightroom:$error'; + } + + @override + String get settings_lightroom_connect_instructions => + '在浏览器窗口中登录 Adobe,然后粘贴你到达页面的完整地址(其中包含授权码)。'; + + @override + String get settings_lightroom_connect_reopenBrowser => '重新打开浏览器'; + + @override + String get settings_lightroom_connect_submit => '连接'; + + @override + String get settings_lightroom_connect_title => '连接 Lightroom'; + + @override + String settings_lightroom_connected(String name) { + return '已连接为 $name'; + } + + @override + String get settings_lightroom_disconnect => '断开连接'; + + @override + String get settings_lightroom_disconnect_confirmBody => + '已关联的照片会保留在你的潜水记录中,并继续从媒体存储中显示。新照片将不再自动匹配。'; + + @override + String get settings_lightroom_disconnect_confirmTitle => '断开 Lightroom 连接?'; + + @override + String settings_lightroom_lastPoll(String when) { + return '上次检查:$when'; + } + + @override + String get settings_lightroom_needsReauth => '需要重新连接'; + + @override + String get settings_lightroom_scanNow => '扫描 Lightroom'; + + @override + String get settings_lightroom_scan_running => '正在扫描 Lightroom...'; + + @override + String settings_lightroom_scan_summary( + int attached, + int suggested, + int skipped, + ) { + return '已关联 $attached 张,建议 $suggested 张,$skipped 张已关联'; + } + + @override + String get settings_lightroom_subtitle => '自动将照片和视频关联到潜水记录'; + + @override + String get settings_lightroom_title => 'Adobe Lightroom'; + @override String get settings_manage_checklistTemplates => '清单模板'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index b7ab1f914..80819c02f 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3259,6 +3259,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{1 foto geimporteerd} other{{imported} foto's geimporteerd}} ({skipped} al gekoppeld)", "media_import_importedPhotos": "{count} {count, plural, =1{foto} other{foto's}} geimporteerd", "media_import_importingPhotos": "{count} {count, plural, =1{foto} other{foto's}} importeren...", + "media_lightroom_openInLightroom": "Openen in Lightroom", + "media_lightroom_suggestion_accept": "Toevoegen aan deze duik", + "media_lightroom_suggestion_dismiss": "Negeren", + "media_lightroom_suggestions_title": "Suggesties uit Lightroom", "media_miniProfile_headerLabel": "Duikprofiel", "media_miniProfile_semanticLabel": "Mini duikprofielgrafiek", "media_photoPicker_appBarTitle": "Foto's selecteren", @@ -3767,6 +3771,31 @@ "settings_language_appBar_title": "Taal", "settings_language_selected": "Geselecteerd", "settings_language_systemDefault": "Systeemstandaard", + "settings_lightroom_albumFilter_all": "Volledige catalogus", + "settings_lightroom_albumFilter_title": "Te scannen albums", + "settings_lightroom_autoPoll_title": "Automatisch controleren op nieuwe foto's", + "settings_lightroom_clientId_help": "Maak in de Adobe Developer Console een integratie met de Lightroom Services API en een referentietype dat PKCE ondersteunt. Stel de redirect-URI in op {redirectUri}.", + "settings_lightroom_clientId_label": "Adobe client-ID", + "settings_lightroom_clientSecret_label": "Client secret (optioneel)", + "settings_lightroom_connect": "Lightroom koppelen", + "settings_lightroom_connect_codeLabel": "Doorgestuurde URL of code", + "settings_lightroom_connect_emptyCode": "Plak de doorgestuurde URL of autorisatiecode", + "settings_lightroom_connect_failed": "Kan geen verbinding maken met Lightroom: {error}", + "settings_lightroom_connect_instructions": "Meld je aan bij Adobe in het browservenster en plak vervolgens het volledige adres van de pagina waarop je terechtkomt (dit bevat de autorisatiecode).", + "settings_lightroom_connect_reopenBrowser": "Browser opnieuw openen", + "settings_lightroom_connect_submit": "Koppelen", + "settings_lightroom_connect_title": "Lightroom koppelen", + "settings_lightroom_connected": "Gekoppeld als {name}", + "settings_lightroom_disconnect": "Ontkoppelen", + "settings_lightroom_disconnect_confirmBody": "Gekoppelde foto's blijven bij je duiken en worden nog steeds weergegeven vanuit de mediaopslag. Nieuwe foto's worden niet meer gekoppeld.", + "settings_lightroom_disconnect_confirmTitle": "Lightroom ontkoppelen?", + "settings_lightroom_lastPoll": "Laatst gecontroleerd: {when}", + "settings_lightroom_needsReauth": "Opnieuw verbinden vereist", + "settings_lightroom_scanNow": "Lightroom scannen", + "settings_lightroom_scan_running": "Lightroom wordt gescand...", + "settings_lightroom_scan_summary": "{attached} gekoppeld, {suggested} voorgesteld, {skipped} al gekoppeld", + "settings_lightroom_subtitle": "Foto's en video's automatisch aan duiken koppelen", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Checklistsjablonen", "settings_manage_checklistTemplates_subtitle": "Herbruikbare takenlijsten voor reisplanning", "settings_manage_diveRoles": "Duikrollen", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index e31eb85f8..4e619370d 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3259,6 +3259,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{1 foto importada} other{{imported} fotos importadas}} ({skipped} ja vinculadas)", "media_import_importedPhotos": "{count} {count, plural, =1{foto importada} other{fotos importadas}}", "media_import_importingPhotos": "Importando {count} {count, plural, =1{foto} other{fotos}}...", + "media_lightroom_openInLightroom": "Abrir no Lightroom", + "media_lightroom_suggestion_accept": "Adicionar a este mergulho", + "media_lightroom_suggestion_dismiss": "Dispensar", + "media_lightroom_suggestions_title": "Sugestões do Lightroom", "media_miniProfile_headerLabel": "Perfil do Mergulho", "media_miniProfile_semanticLabel": "Grafico mini do perfil de mergulho", "media_photoPicker_appBarTitle": "Selecionar Fotos", @@ -3767,6 +3771,31 @@ "settings_language_appBar_title": "Idioma", "settings_language_selected": "Selecionado", "settings_language_systemDefault": "Padrao do Sistema", + "settings_lightroom_albumFilter_all": "Catálogo inteiro", + "settings_lightroom_albumFilter_title": "Álbuns a analisar", + "settings_lightroom_autoPoll_title": "Verificar novas fotos automaticamente", + "settings_lightroom_clientId_help": "Crie uma integração na Adobe Developer Console com a API Lightroom Services e um tipo de credencial compatível com PKCE. Defina a URI de redirecionamento como {redirectUri}.", + "settings_lightroom_clientId_label": "ID de cliente da Adobe", + "settings_lightroom_clientSecret_label": "Segredo do cliente (opcional)", + "settings_lightroom_connect": "Conectar Lightroom", + "settings_lightroom_connect_codeLabel": "URL redirecionada ou código", + "settings_lightroom_connect_emptyCode": "Cole a URL redirecionada ou o código de autorização", + "settings_lightroom_connect_failed": "Não foi possível conectar ao Lightroom: {error}", + "settings_lightroom_connect_instructions": "Inicie sessão na Adobe na janela do navegador e cole o endereço completo da página em que você chegar (ele contém o código de autorização).", + "settings_lightroom_connect_reopenBrowser": "Reabrir navegador", + "settings_lightroom_connect_submit": "Conectar", + "settings_lightroom_connect_title": "Conectar Lightroom", + "settings_lightroom_connected": "Conectado como {name}", + "settings_lightroom_disconnect": "Desconectar", + "settings_lightroom_disconnect_confirmBody": "As fotos vinculadas permanecem nos seus mergulhos e continuam sendo exibidas a partir do armazenamento de mídia. Novas fotos não serão mais vinculadas.", + "settings_lightroom_disconnect_confirmTitle": "Desconectar o Lightroom?", + "settings_lightroom_lastPoll": "Última verificação: {when}", + "settings_lightroom_needsReauth": "Reconexão necessária", + "settings_lightroom_scanNow": "Analisar Lightroom", + "settings_lightroom_scan_running": "Analisando o Lightroom...", + "settings_lightroom_scan_summary": "{attached} vinculadas, {suggested} sugeridas, {skipped} já vinculadas", + "settings_lightroom_subtitle": "Vincular fotos e vídeos aos mergulhos automaticamente", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "Modelos de Lista de Verificação", "settings_manage_checklistTemplates_subtitle": "Listas de tarefas reutilizáveis para planejamento de viagens", "settings_manage_diveRoles": "Funções de Mergulho", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index a90a2bba2..935e3477b 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3410,6 +3410,10 @@ "media_import_importedAndSkipped": "{imported, plural, =1{已导入 1 张照片} other{已导入 {imported} 张照片}}({skipped} 张已关联)", "media_import_importedPhotos": "已导入 {count} {count, plural, =1{张照片} other{张照片}}", "media_import_importingPhotos": "正在导入 {count} {count, plural, =1{张照片} other{张照片}}...", + "media_lightroom_openInLightroom": "在 Lightroom 中打开", + "media_lightroom_suggestion_accept": "添加到此潜水", + "media_lightroom_suggestion_dismiss": "忽略", + "media_lightroom_suggestions_title": "来自 Lightroom 的建议", "media_miniProfile_headerLabel": "潜水轮廓", "media_miniProfile_semanticLabel": "迷你潜水轮廓图", "media_photoPicker_appBarTitle": "选择照片", @@ -3934,6 +3938,31 @@ "settings_language_appBar_title": "语言", "settings_language_selected": "已选择", "settings_language_systemDefault": "系统默认", + "settings_lightroom_albumFilter_all": "整个目录", + "settings_lightroom_albumFilter_title": "要扫描的相册", + "settings_lightroom_autoPoll_title": "自动检查新照片", + "settings_lightroom_clientId_help": "在 Adobe Developer Console 中使用 Lightroom Services API 创建集成,并选择支持 PKCE 的凭据类型。将重定向 URI 设置为 {redirectUri}。", + "settings_lightroom_clientId_label": "Adobe 客户端 ID", + "settings_lightroom_clientSecret_label": "客户端密钥(可选)", + "settings_lightroom_connect": "连接 Lightroom", + "settings_lightroom_connect_codeLabel": "重定向的网址或代码", + "settings_lightroom_connect_emptyCode": "粘贴重定向的网址或授权码", + "settings_lightroom_connect_failed": "无法连接到 Lightroom:{error}", + "settings_lightroom_connect_instructions": "在浏览器窗口中登录 Adobe,然后粘贴你到达页面的完整地址(其中包含授权码)。", + "settings_lightroom_connect_reopenBrowser": "重新打开浏览器", + "settings_lightroom_connect_submit": "连接", + "settings_lightroom_connect_title": "连接 Lightroom", + "settings_lightroom_connected": "已连接为 {name}", + "settings_lightroom_disconnect": "断开连接", + "settings_lightroom_disconnect_confirmBody": "已关联的照片会保留在你的潜水记录中,并继续从媒体存储中显示。新照片将不再自动匹配。", + "settings_lightroom_disconnect_confirmTitle": "断开 Lightroom 连接?", + "settings_lightroom_lastPoll": "上次检查:{when}", + "settings_lightroom_needsReauth": "需要重新连接", + "settings_lightroom_scanNow": "扫描 Lightroom", + "settings_lightroom_scan_running": "正在扫描 Lightroom...", + "settings_lightroom_scan_summary": "已关联 {attached} 张,建议 {suggested} 张,{skipped} 张已关联", + "settings_lightroom_subtitle": "自动将照片和视频关联到潜水记录", + "settings_lightroom_title": "Adobe Lightroom", "settings_manage_checklistTemplates": "清单模板", "settings_manage_checklistTemplates_subtitle": "用于旅行规划的可重复使用待办清单", "settings_manage_diveRoles": "潜水角色", diff --git a/test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart b/test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart new file mode 100644 index 000000000..d92aa0912 --- /dev/null +++ b/test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.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/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; +import 'package:submersion/features/settings/presentation/widgets/lightroom_connect_dialog.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + AdobeImsAuthManager manager(MockClient mock) => AdobeImsAuthManager( + store: LightroomAuthStore(storage: InMemoryKeychain()), + httpClient: mock, + verifierGenerator: () => 'a' * 43, + ); + + MockClient happyMock() => MockClient( + (request) async => http.Response( + '{"access_token":"at","refresh_token":"rt","expires_in":3600}', + 200, + headers: {'content-type': 'application/json'}, + ), + ); + + Future pumpDialog( + WidgetTester tester, + AdobeImsAuthManager auth, { + List? opened, + }) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => LightroomConnectDialog( + authManager: auth, + clientId: 'cid', + clientSecret: 'sec', + openUri: (uri) async { + opened?.add(uri); + return true; + }, + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('opens the IMS authorize URL on launch', (tester) async { + final opened = []; + await pumpDialog(tester, manager(happyMock()), opened: opened); + expect(opened, hasLength(1)); + expect(opened.single.host, 'ims-na1.adobelogin.com'); + expect(opened.single.queryParameters['client_id'], 'cid'); + }); + + testWidgets('empty submit shows the empty-code error', (tester) async { + await pumpDialog(tester, manager(happyMock())); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + expect( + find.text('Paste the redirected URL or authorization code'), + findsOneWidget, + ); + }); + + testWidgets('pasting a redirected URL exchanges the code and pops true', ( + tester, + ) async { + final requests = []; + final mock = MockClient((request) async { + requests.add(request); + return http.Response( + '{"access_token":"at","refresh_token":"rt","expires_in":3600}', + 200, + headers: {'content-type': 'application/json'}, + ); + }); + await pumpDialog(tester, manager(mock)); + await tester.enterText( + find.byType(TextField), + 'https://submersion.app/lightroom/callback?code=xyz', + ); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + + expect(find.byType(LightroomConnectDialog), findsNothing); + final body = Uri.splitQueryString(requests.single.body); + expect(body['code'], 'xyz'); + expect(body['code_verifier'], 'a' * 43); + }); + + testWidgets('a rejected exchange surfaces the error in the field', ( + tester, + ) async { + final mock = MockClient((_) async => http.Response('denied', 400)); + await pumpDialog(tester, manager(mock)); + await tester.enterText(find.byType(TextField), 'badcode'); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + + expect(find.byType(LightroomConnectDialog), findsOneWidget); + expect( + find.textContaining('Could not connect to Lightroom'), + findsOneWidget, + ); + }); +} From fa30f340d77c7fde82862aad5e1eec2ef6e37edd Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 08:12:03 -0400 Subject: [PATCH 18/22] feat(lightroom): scan actions on dive detail and trip overview, Open in Lightroom in viewer --- .../presentation/pages/photo_viewer_page.dart | 36 ++++++ .../widgets/dive_media_section.dart | 21 ++++ .../widgets/trip_overview_tab.dart | 22 ++++ .../widgets/trip_photo_section.dart | 18 ++- .../lightroom_scan_helper_test.dart | 113 ++++++++++++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 test/features/media/presentation/lightroom_scan_helper_test.dart diff --git a/lib/features/media/presentation/pages/photo_viewer_page.dart b/lib/features/media/presentation/pages/photo_viewer_page.dart index 69b67601b..1bed4e73f 100644 --- a/lib/features/media/presentation/pages/photo_viewer_page.dart +++ b/lib/features/media/presentation/pages/photo_viewer_page.dart @@ -7,13 +7,17 @@ import 'package:path_provider/path_provider.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/media/data/services/metadata_write_service.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; import 'package:submersion/features/media/presentation/widgets/media_item_view.dart'; @@ -156,6 +160,12 @@ class _PhotoViewerPageState extends ConsumerState { onShare: () => _shareCurrentPhoto(currentItem), onWriteMetadata: () => _writeMetadataToPhoto(currentItem), hasEnrichment: enrichment?.depthMeters != null, + onOpenInLightroom: _lightroomWebUrl(currentItem) == null + ? null + : () => launchUrl( + Uri.parse(_lightroomWebUrl(currentItem)!), + mode: LaunchMode.externalApplication, + ), ), // Mini dive profile overlay (lower right) @@ -195,6 +205,22 @@ class _PhotoViewerPageState extends ConsumerState { ); } + /// The lightroom.adobe.com URL for a connector item, or null when the + /// item is not Lightroom-linked or this device has no connected account + /// (the catalog id lives only on the connected device). + String? _lightroomWebUrl(MediaItem item) { + if (item.sourceType != MediaSourceType.serviceConnector || + item.remoteAssetId == null) { + return null; + } + final catalogId = ref + .watch(lightroomAccountProvider) + .value + ?.accountIdentifier; + if (catalogId == null) return null; + return LightroomApiClient.assetWebUrl(catalogId, item.remoteAssetId!); + } + Future _shareCurrentPhoto(MediaItem item) async { final l10n = context.l10n; @@ -788,6 +814,9 @@ class _TopOverlay extends StatelessWidget { final VoidCallback onWriteMetadata; final bool hasEnrichment; + /// Non-null only for Lightroom-linked items on the connected device. + final VoidCallback? onOpenInLightroom; + const _TopOverlay({ required this.currentIndex, required this.totalCount, @@ -795,6 +824,7 @@ class _TopOverlay extends StatelessWidget { required this.onShare, required this.onWriteMetadata, required this.hasEnrichment, + this.onOpenInLightroom, }); @override @@ -845,6 +875,12 @@ class _TopOverlay extends StatelessWidget { context.l10n.media_photoViewer_writeDiveDataTooltip, onPressed: onWriteMetadata, ), + if (onOpenInLightroom != null) + IconButton( + icon: const Icon(Icons.open_in_new, color: Colors.white), + tooltip: context.l10n.media_lightroom_openInLightroom, + onPressed: onOpenInLightroom, + ), IconButton( icon: const Icon(Icons.share, color: Colors.white), tooltip: context.l10n.media_photoViewer_shareTooltip, diff --git a/lib/features/media/presentation/widgets/dive_media_section.dart b/lib/features/media/presentation/widgets/dive_media_section.dart index b83dd21c9..9b42fd0a4 100644 --- a/lib/features/media/presentation/widgets/dive_media_section.dart +++ b/lib/features/media/presentation/widgets/dive_media_section.dart @@ -6,6 +6,9 @@ import 'package:uuid/uuid.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/media/presentation/helpers/lightroom_scan_helper.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; import 'package:submersion/features/media/presentation/pages/photo_viewer_page.dart'; @@ -60,6 +63,14 @@ class _DiveMediaSectionState extends ConsumerState { bool _isSelectionMode = false; Set _selectedIndices = {}; + Future _scanLightroom(BuildContext context) async { + final dive = await ref + .read(diveRepositoryProvider) + .getDiveById(widget.diveId); + if (dive == null || !context.mounted) return; + await runLightroomScan(context, ref, [dive]); + } + void _exitSelectionMode() { setState(() { _isSelectionMode = false; @@ -293,6 +304,16 @@ class _DiveMediaSectionState extends ConsumerState { tooltip: context.l10n.media_diveScan_scanTooltip, onPressed: widget.onScanPressed, ), + if (ref.watch(lightroomAccountProvider).value != null) + IconButton( + icon: Icon( + Icons.cloud_sync_outlined, + color: colorScheme.primary, + ), + visualDensity: VisualDensity.compact, + tooltip: context.l10n.settings_lightroom_scanNow, + onPressed: () => _scanLightroom(context), + ), if (widget.onAddPressed != null) IconButton( icon: Icon( diff --git a/lib/features/trips/presentation/widgets/trip_overview_tab.dart b/lib/features/trips/presentation/widgets/trip_overview_tab.dart index 6d6759258..bc2ce98ab 100644 --- a/lib/features/trips/presentation/widgets/trip_overview_tab.dart +++ b/lib/features/trips/presentation/widgets/trip_overview_tab.dart @@ -16,6 +16,8 @@ import 'package:submersion/features/maps/domain/map_utils.dart'; import 'package:submersion/features/maps/presentation/providers/map_tile_providers.dart'; import 'package:submersion/features/maps/presentation/widgets/map_attribution.dart'; import 'package:submersion/features/maps/presentation/widgets/trackpad_zoom_map.dart'; +import 'package:submersion/features/media/presentation/helpers/lightroom_scan_helper.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/data/services/photo_picker_service.dart'; import 'package:submersion/features/media/data/services/trip_media_scanner.dart'; @@ -119,6 +121,10 @@ class _TripOverviewTabState extends ConsumerState { TripPhotoSection( tripId: trip.id, onScanPressed: () => _showScanDialog(context, ref, trip.id), + onLightroomScanPressed: + ref.watch(lightroomAccountProvider).value == null + ? null + : () => _scanLightroom(context, ref, trip.id), ), const SizedBox(height: 24), @@ -591,6 +597,22 @@ class _TripOverviewTabState extends ConsumerState { ); } + Future _scanLightroom( + BuildContext context, + WidgetRef ref, + String tripId, + ) async { + final dives = await ref.read(divesForTripProvider(tripId).future); + if (!context.mounted) return; + if (dives.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trips_detail_scan_addDivesFirst)), + ); + return; + } + await runLightroomScan(context, ref, dives); + } + Future _showScanDialog( BuildContext context, WidgetRef ref, diff --git a/lib/features/trips/presentation/widgets/trip_photo_section.dart b/lib/features/trips/presentation/widgets/trip_photo_section.dart index d1e2a53bd..5716a48de 100644 --- a/lib/features/trips/presentation/widgets/trip_photo_section.dart +++ b/lib/features/trips/presentation/widgets/trip_photo_section.dart @@ -14,8 +14,14 @@ const int _maxVisibleThumbnails = 5; class TripPhotoSection extends ConsumerWidget { final String tripId; final VoidCallback? onScanPressed; + final VoidCallback? onLightroomScanPressed; - const TripPhotoSection({super.key, required this.tripId, this.onScanPressed}); + const TripPhotoSection({ + super.key, + required this.tripId, + this.onScanPressed, + this.onLightroomScanPressed, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -73,6 +79,16 @@ class TripPhotoSection extends ConsumerWidget { tooltip: context.l10n.trips_photos_tooltip_scan, onPressed: onScanPressed, ), + if (onLightroomScanPressed != null) + IconButton( + icon: Icon( + Icons.cloud_sync_outlined, + color: colorScheme.primary, + ), + visualDensity: VisualDensity.compact, + tooltip: context.l10n.settings_lightroom_scanNow, + onPressed: onLightroomScanPressed, + ), ], ), const SizedBox(height: 16), diff --git a/test/features/media/presentation/lightroom_scan_helper_test.dart b/test/features/media/presentation/lightroom_scan_helper_test.dart new file mode 100644 index 000000000..a79cc6ba8 --- /dev/null +++ b/test/features/media/presentation/lightroom_scan_helper_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.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/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; +import 'package:submersion/features/media/data/services/lightroom_scan_service.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; +import 'package:submersion/features/media/presentation/helpers/lightroom_scan_helper.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +class _FakeScanService extends LightroomScanService { + _FakeScanService() + : super( + api: LightroomApiClient(auth: AdobeImsAuthManager()), + mediaRepository: MediaRepository(), + diveRepository: DiveRepository(), + enqueueUpload: (_) {}, + ); + + int scanCalls = 0; + + @override + Future scanDives({ + required domain.ConnectorAccount account, + required List dives, + required LightroomConnectorState state, + }) async { + scanCalls++; + return LightroomScanSummary() + ..attached = 3 + ..suggested = 1 + ..skippedExisting = 2; + } +} + +void main() { + final account = domain.ConnectorAccount( + id: 'acct1', + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + addedAt: DateTime.utc(2026, 7, 1), + ); + + Future<(_FakeScanService, WidgetTester)> pump( + WidgetTester tester, { + domain.ConnectorAccount? withAccount, + }) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = _FakeScanService(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomAccountProvider.overrideWith((ref) async => withAccount), + lightroomScanServiceProvider.overrideWithValue(service), + ], + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => ElevatedButton( + onPressed: () => runLightroomScan(context, ref, [ + Dive(id: 'd1', dateTime: DateTime.utc(2026, 7, 1, 10)), + ]), + child: const Text('scan'), + ), + ), + ), + ), + ), + ); + return (service, tester); + } + + testWidgets('shows the summary snackbar after a scan', (tester) async { + final (service, _) = await pump(tester, withAccount: account); + await tester.tap(find.text('scan')); + await tester.pumpAndSettle(); + + expect(service.scanCalls, 1); + expect( + find.text('3 linked, 1 suggested, 2 already linked'), + findsOneWidget, + ); + }); + + testWidgets('does nothing without an account', (tester) async { + final (service, _) = await pump(tester); + await tester.tap(find.text('scan')); + await tester.pumpAndSettle(); + + expect(service.scanCalls, 0); + expect(find.byType(SnackBar), findsNothing); + }); +} From c5fe657f8d26d622ad07b89fd036f66a1be7d036 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 08:14:35 -0400 Subject: [PATCH 19/22] feat(lightroom): suggestion row with confirm and dismiss on dive detail --- .../widgets/dive_media_section.dart | 2 + .../widgets/lightroom_suggestions_row.dart | 166 ++++++++++++++++++ .../lightroom_suggestions_row_test.dart | 145 +++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 lib/features/media/presentation/widgets/lightroom_suggestions_row.dart create mode 100644 test/features/media/presentation/lightroom_suggestions_row_test.dart diff --git a/lib/features/media/presentation/widgets/dive_media_section.dart b/lib/features/media/presentation/widgets/dive_media_section.dart index 9b42fd0a4..28d3fe338 100644 --- a/lib/features/media/presentation/widgets/dive_media_section.dart +++ b/lib/features/media/presentation/widgets/dive_media_section.dart @@ -14,6 +14,7 @@ import 'package:submersion/features/media/domain/entities/media_source_type.dart import 'package:submersion/features/media/presentation/pages/photo_viewer_page.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/lightroom_suggestions_row.dart'; import 'package:submersion/features/media/presentation/widgets/media_item_view.dart'; import 'package:submersion/features/media_store/presentation/widgets/media_store_badge.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -401,6 +402,7 @@ class _DiveMediaSectionState extends ConsumerState { style: textTheme.bodyMedium?.copyWith(color: colorScheme.error), ), ), + LightroomSuggestionsRow(diveId: widget.diveId), ], ), ), diff --git a/lib/features/media/presentation/widgets/lightroom_suggestions_row.dart b/lib/features/media/presentation/widgets/lightroom_suggestions_row.dart new file mode 100644 index 000000000..f919dd413 --- /dev/null +++ b/lib/features/media/presentation/widgets/lightroom_suggestions_row.dart @@ -0,0 +1,166 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/media/domain/entities/media_item.dart' + as domain; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Ambiguous Lightroom matches for a dive: a horizontal row of suggestion +/// cards with per-card accept and dismiss. Renders nothing when there are +/// no live connector suggestions or no connected account. +class LightroomSuggestionsRow extends ConsumerWidget { + const LightroomSuggestionsRow({required this.diveId, super.key}); + + final String diveId; + + Future _accept( + WidgetRef ref, + domain.PendingPhotoSuggestion suggestion, + ) async { + final account = await ref.read(lightroomAccountProvider.future); + if (account == null) return; + await ref + .read(lightroomScanServiceProvider) + .confirmSuggestion(account: account, suggestion: suggestion); + ref.invalidate(pendingSuggestionsForDiveProvider(diveId)); + ref.invalidate(mediaForDiveProvider(diveId)); + } + + Future _dismiss( + WidgetRef ref, + domain.PendingPhotoSuggestion suggestion, + ) async { + await ref + .read(mediaRepositoryProvider) + .dismissPendingSuggestion(suggestion.id); + ref.invalidate(pendingSuggestionsForDiveProvider(diveId)); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + // .value keeps showing the previous list during reloads instead of + // flashing empty (AsyncValue reload-flicker trap). + final suggestions = + ref + .watch(pendingSuggestionsForDiveProvider(diveId)) + .value + ?.where((s) => s.remoteAssetId != null) + .toList() ?? + const []; + if (suggestions.isEmpty) return const SizedBox.shrink(); + + final textTheme = Theme.of(context).textTheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + context.l10n.media_lightroom_suggestions_title, + style: textTheme.titleSmall, + ), + const SizedBox(height: 8), + SizedBox( + height: 132, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + return _SuggestionCard( + suggestion: suggestion, + onAccept: () => _accept(ref, suggestion), + onDismiss: () => _dismiss(ref, suggestion), + ); + }, + ), + ), + ], + ); + } +} + +class _SuggestionCard extends ConsumerWidget { + const _SuggestionCard({ + required this.suggestion, + required this.onAccept, + required this.onDismiss, + }); + + final domain.PendingPhotoSuggestion suggestion; + final VoidCallback onAccept; + final VoidCallback onDismiss; + + Future _thumbnail(WidgetRef ref) async { + final account = await ref.read(lightroomAccountProvider.future); + final catalogId = account?.accountIdentifier; + final assetId = suggestion.remoteAssetId; + if (catalogId == null || assetId == null) return null; + try { + return await ref + .read(lightroomApiClientProvider) + .getRendition( + catalogId: catalogId, + assetId: assetId, + size: 'thumbnail2x', + ); + } on Exception { + return null; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + return SizedBox( + width: 120, + child: Column( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FutureBuilder( + future: _thumbnail(ref), + builder: (context, snapshot) { + final bytes = snapshot.data; + if (bytes == null) { + return ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: const Center(child: Icon(Icons.photo_outlined)), + ); + } + return Image.memory( + bytes, + fit: BoxFit.cover, + width: double.infinity, + ); + }, + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: Icon(Icons.check, color: colorScheme.primary), + visualDensity: VisualDensity.compact, + tooltip: context.l10n.media_lightroom_suggestion_accept, + onPressed: onAccept, + ), + IconButton( + icon: Icon(Icons.close, color: colorScheme.outline), + visualDensity: VisualDensity.compact, + tooltip: context.l10n.media_lightroom_suggestion_dismiss, + onPressed: onDismiss, + ), + ], + ), + ], + ), + ); + } +} diff --git a/test/features/media/presentation/lightroom_suggestions_row_test.dart b/test/features/media/presentation/lightroom_suggestions_row_test.dart new file mode 100644 index 000000000..e1d34b216 --- /dev/null +++ b/test/features/media/presentation/lightroom_suggestions_row_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.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/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; +import 'package:submersion/features/media/data/services/lightroom_scan_service.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; +import 'package:submersion/features/media/domain/entities/media_item.dart' + as domain; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/lightroom_suggestions_row.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +class _RecordingScanService extends LightroomScanService { + _RecordingScanService() + : super( + api: LightroomApiClient(auth: AdobeImsAuthManager()), + mediaRepository: MediaRepository(), + diveRepository: DiveRepository(), + enqueueUpload: (_) {}, + ); + + final confirmed = []; + + @override + Future confirmSuggestion({ + required domain.ConnectorAccount account, + required domain.PendingPhotoSuggestion suggestion, + }) async { + confirmed.add(suggestion); + } +} + +class _RecordingMediaRepository extends MediaRepository { + final dismissed = []; + List suggestions = []; + + @override + Future> getPendingSuggestionsForDive( + String diveId, + ) async => suggestions; + + @override + Future dismissPendingSuggestion(String id) async { + dismissed.add(id); + suggestions = [ + for (final s in suggestions) + if (s.id != id) s, + ]; + } +} + +void main() { + final account = domain.ConnectorAccount( + id: 'acct1', + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + addedAt: DateTime.utc(2026, 7, 1), + ); + + domain.PendingPhotoSuggestion suggestion(String id) => + domain.PendingPhotoSuggestion( + id: id, + diveId: 'd1', + platformAssetId: 'lr-$id', + takenAt: DateTime.utc(2026, 7, 1, 12), + createdAt: DateTime.utc(2026, 7, 2), + connectorAccountId: 'acct1', + remoteAssetId: 'lr-$id', + ); + + Future<(_RecordingScanService, _RecordingMediaRepository)> pump( + WidgetTester tester, { + required List suggestions, + }) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = _RecordingScanService(); + final repository = _RecordingMediaRepository()..suggestions = suggestions; + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomAccountProvider.overrideWith((ref) async => account), + lightroomScanServiceProvider.overrideWithValue(service), + mediaRepositoryProvider.overrideWithValue(repository), + ], + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SingleChildScrollView( + child: LightroomSuggestionsRow(diveId: 'd1'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return (service, repository); + } + + testWidgets('renders nothing with no suggestions', (tester) async { + await pump(tester, suggestions: []); + expect(find.text('Suggested from Lightroom'), findsNothing); + }); + + testWidgets('shows cards and confirms via the accept button', (tester) async { + final (service, _) = await pump( + tester, + suggestions: [suggestion('s1'), suggestion('s2')], + ); + expect(find.text('Suggested from Lightroom'), findsOneWidget); + expect(find.byTooltip('Add to this dive'), findsNWidgets(2)); + + await tester.tap(find.byTooltip('Add to this dive').first); + await tester.pumpAndSettle(); + expect(service.confirmed.single.id, 's1'); + }); + + testWidgets('dismiss hides the card', (tester) async { + final (_, repository) = await pump(tester, suggestions: [suggestion('s1')]); + await tester.tap(find.byTooltip('Dismiss')); + await tester.pumpAndSettle(); + + expect(repository.dismissed, ['s1']); + expect(find.text('Suggested from Lightroom'), findsNothing); + }); +} From 991610627dd8b49eaf88a206d65c3a3656cdfbae Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 08:15:52 -0400 Subject: [PATCH 20/22] style(lightroom): clean suggestions-row test lints --- .../presentation/lightroom_suggestions_row_test.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/features/media/presentation/lightroom_suggestions_row_test.dart b/test/features/media/presentation/lightroom_suggestions_row_test.dart index e1d34b216..ab5328672 100644 --- a/test/features/media/presentation/lightroom_suggestions_row_test.dart +++ b/test/features/media/presentation/lightroom_suggestions_row_test.dart @@ -6,9 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; import 'package:submersion/core/services/lightroom/lightroom_api_client.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/media/data/repositories/media_repository.dart'; -import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; import 'package:submersion/features/media/data/services/lightroom_scan_service.dart'; import 'package:submersion/features/media/domain/entities/connector_account.dart' as domain; @@ -96,15 +94,15 @@ void main() { lightroomScanServiceProvider.overrideWithValue(service), mediaRepositoryProvider.overrideWithValue(repository), ], - child: MaterialApp( - localizationsDelegates: const [ + child: const MaterialApp( + localizationsDelegates: [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( + home: Scaffold( body: SingleChildScrollView( child: LightroomSuggestionsRow(diveId: 'd1'), ), From c4e1e5730538c334673643383b8ede6a17600261 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 08:17:43 -0400 Subject: [PATCH 21/22] feat(lightroom): startup auto-poll trigger on the dive list page --- .../presentation/pages/dive_list_page.dart | 12 ++ .../lightroom_auto_poll_test.dart | 109 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 test/features/media/presentation/lightroom_auto_poll_test.dart diff --git a/lib/features/dive_log/presentation/pages/dive_list_page.dart b/lib/features/dive_log/presentation/pages/dive_list_page.dart index 765211640..5852b0d59 100644 --- a/lib/features/dive_log/presentation/pages/dive_list_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_list_page.dart @@ -10,6 +10,7 @@ import 'package:submersion/core/constants/list_view_mode.dart'; import 'package:submersion/core/constants/map_style.dart'; import 'package:submersion/core/constants/map_tile_config.dart'; import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/shared/widgets/master_detail/master_detail_scaffold.dart'; import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -70,6 +71,17 @@ class _DiveListPageState extends ConsumerState { /// Tracks the selected dive ID for mobile map view info card String? _mobileMapSelectedDiveId; + @override + void initState() { + super.initState(); + // Home-tab startup hook for the Lightroom auto-poll: the provider + // itself gates on account, toggle, and a 6-hour interval, and catches + // every failure, so this read is fire-and-forget. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(lightroomAutoPollProvider); + }); + } + bool get _isMapView { final state = GoRouterState.of(context); return state.uri.queryParameters['view'] == 'map'; diff --git a/test/features/media/presentation/lightroom_auto_poll_test.dart b/test/features/media/presentation/lightroom_auto_poll_test.dart new file mode 100644 index 000000000..87d755a19 --- /dev/null +++ b/test/features/media/presentation/lightroom_auto_poll_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/media/data/repositories/media_repository.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; +import 'package:submersion/features/media/data/services/lightroom_scan_service.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +class _CountingScanService extends LightroomScanService { + _CountingScanService() + : super( + api: LightroomApiClient(auth: AdobeImsAuthManager()), + mediaRepository: MediaRepository(), + diveRepository: DiveRepository(), + enqueueUpload: (_) {}, + ); + + int pollCalls = 0; + + @override + Future poll({ + required domain.ConnectorAccount account, + required LightroomConnectorState state, + }) async { + pollCalls++; + await state.setLastPollAt(DateTime.now()); + return LightroomScanSummary(); + } +} + +void main() { + final account = domain.ConnectorAccount( + id: 'acct1', + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + addedAt: DateTime.utc(2026, 7, 1), + ); + + late SharedPreferences prefs; + late _CountingScanService service; + + ProviderContainer container({domain.ConnectorAccount? withAccount}) { + final c = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomAccountProvider.overrideWith((ref) async => withAccount), + lightroomScanServiceProvider.overrideWithValue(service), + ], + ); + addTearDown(c.dispose); + return c; + } + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + service = _CountingScanService(); + }); + + test('polls when enabled and never polled before', () async { + final c = container(withAccount: account); + await c.read(lightroomAutoPollProvider.future); + expect(service.pollCalls, 1); + }); + + test('skips when auto-poll is disabled', () async { + await LightroomConnectorState( + prefs: prefs, + accountId: account.id, + ).setAutoPollEnabled(false); + final c = container(withAccount: account); + await c.read(lightroomAutoPollProvider.future); + expect(service.pollCalls, 0); + }); + + test('skips when the last poll is recent', () async { + await LightroomConnectorState( + prefs: prefs, + accountId: account.id, + ).setLastPollAt(DateTime.now().subtract(const Duration(hours: 1))); + final c = container(withAccount: account); + await c.read(lightroomAutoPollProvider.future); + expect(service.pollCalls, 0); + }); + + test('polls again when the last poll is stale', () async { + await LightroomConnectorState( + prefs: prefs, + accountId: account.id, + ).setLastPollAt(DateTime.now().subtract(const Duration(hours: 7))); + final c = container(withAccount: account); + await c.read(lightroomAutoPollProvider.future); + expect(service.pollCalls, 1); + }); + + test('no-op without an account', () async { + final c = container(); + await c.read(lightroomAutoPollProvider.future); + expect(service.pollCalls, 0); + }); +} From bed668c331bcc3765d000930d35890e29f81b6e1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 14:42:58 -0400 Subject: [PATCH 22/22] test(lightroom): raise patch coverage - settings page suite, dialog error paths, viewer action, entity equality, auto-poll failure --- macos/Podfile.lock | 12 + .../entities/connector_account_test.dart | 32 ++ .../lightroom_auto_poll_test.dart | 15 + .../lightroom_providers_test.dart | 52 ++- .../lightroom_scan_helper_test.dart | 20 + .../pages/photo_viewer_lightroom_test.dart | 107 +++++ .../dive_media_section_lightroom_test.dart | 88 ++++ .../pages/lightroom_settings_page_test.dart | 399 ++++++++++++++++++ .../lightroom_connect_dialog_test.dart | 125 ++++++ .../trip_photo_section_lightroom_test.dart | 55 +++ 10 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 test/features/media/domain/entities/connector_account_test.dart create mode 100644 test/features/media/presentation/pages/photo_viewer_lightroom_test.dart create mode 100644 test/features/media/presentation/widgets/dive_media_section_lightroom_test.dart create mode 100644 test/features/settings/presentation/pages/lightroom_settings_page_test.dart create mode 100644 test/features/trips/presentation/widgets/trip_photo_section_lightroom_test.dart diff --git a/macos/Podfile.lock b/macos/Podfile.lock index a6b5d3b24..2d168b1ad 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -12,6 +12,10 @@ PODS: - auto_updater_macos (0.0.1): - FlutterMacOS - Sparkle + - connectivity_plus (0.0.1): + - FlutterMacOS + - cryptography_flutter (0.0.1): + - FlutterMacOS - desktop_drop (0.0.1): - FlutterMacOS - device_info_plus (0.0.1): @@ -120,6 +124,8 @@ PODS: DEPENDENCIES: - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) + - cryptography_flutter (from `Flutter/ephemeral/.symlinks/plugins/cryptography_flutter/macos`) - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) @@ -160,6 +166,10 @@ SPEC REPOS: EXTERNAL SOURCES: auto_updater_macos: :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos + cryptography_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/cryptography_flutter/macos desktop_drop: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos device_info_plus: @@ -211,6 +221,8 @@ SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 + connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e + cryptography_flutter: be2b3e0e31603521b6a1c2bea232a88a2488a91c desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a diff --git a/test/features/media/domain/entities/connector_account_test.dart b/test/features/media/domain/entities/connector_account_test.dart new file mode 100644 index 000000000..c0afa7c44 --- /dev/null +++ b/test/features/media/domain/entities/connector_account_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart'; + +void main() { + ConnectorAccount account({ + String id = 'a1', + String displayName = 'Eric', + String? accountIdentifier = 'cat1', + DateTime? lastUsedAt, + }) => ConnectorAccount( + id: id, + connectorType: 'lightroom', + displayName: displayName, + credentialsRef: 'lightroom_auth', + accountIdentifier: accountIdentifier, + baseUrl: null, + addedAt: DateTime.utc(2026, 7, 1), + lastUsedAt: lastUsedAt, + ); + + test('value equality covers every field', () { + expect(account(), account()); + expect(account(), isNot(account(id: 'a2'))); + expect(account(), isNot(account(displayName: 'Other'))); + expect(account(), isNot(account(accountIdentifier: null))); + expect(account(), isNot(account(lastUsedAt: DateTime.utc(2026, 7, 2)))); + }); + + test('hashCode agrees with equality', () { + expect(account().hashCode, account().hashCode); + }); +} diff --git a/test/features/media/presentation/lightroom_auto_poll_test.dart b/test/features/media/presentation/lightroom_auto_poll_test.dart index 87d755a19..35dc1d20f 100644 --- a/test/features/media/presentation/lightroom_auto_poll_test.dart +++ b/test/features/media/presentation/lightroom_auto_poll_test.dart @@ -22,6 +22,7 @@ class _CountingScanService extends LightroomScanService { ); int pollCalls = 0; + Object? throwOnPoll; @override Future poll({ @@ -29,6 +30,7 @@ class _CountingScanService extends LightroomScanService { required LightroomConnectorState state, }) async { pollCalls++; + if (throwOnPoll != null) throw throwOnPoll!; await state.setLastPollAt(DateTime.now()); return LightroomScanSummary(); } @@ -106,4 +108,17 @@ void main() { await c.read(lightroomAutoPollProvider.future); expect(service.pollCalls, 0); }); + + test('a failed poll is swallowed and recorded as the last error', () async { + service.throwOnPoll = Exception('network down'); + final c = container(withAccount: account); + await c.read(lightroomAutoPollProvider.future); + + expect(service.pollCalls, 1); + final lastError = await LightroomConnectorState( + prefs: prefs, + accountId: account.id, + ).lastError(); + expect(lastError, contains('network down')); + }); } diff --git a/test/features/media/presentation/lightroom_providers_test.dart b/test/features/media/presentation/lightroom_providers_test.dart index bfe8665d8..78fbc4db6 100644 --- a/test/features/media/presentation/lightroom_providers_test.dart +++ b/test/features/media/presentation/lightroom_providers_test.dart @@ -1,14 +1,21 @@ 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:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; import 'package:submersion/features/media/data/services/media_source_resolver_registry.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import '../../../helpers/test_database.dart'; +import '../../../support/fake_keychain_storage.dart'; void main() { late ProviderContainer container; @@ -17,8 +24,27 @@ void main() { await setUpTestDatabase(); SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); + final store = LightroomAuthStore(storage: InMemoryKeychain()); + await store.save( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt'), + ); container = ProviderContainer( - overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomApiClientProvider.overrideWithValue( + LightroomApiClient( + auth: AdobeImsAuthManager( + store: store, + httpClient: MockClient( + (_) async => http.Response('unavailable', 500), + ), + ), + httpClient: MockClient( + (_) async => http.Response('unavailable', 500), + ), + ), + ), + ], ); addTearDown(container.dispose); }); @@ -74,4 +100,28 @@ void main() { // Nothing to assert beyond not throwing: no account means no state // writes and no API construction. }); + + test('with an account, resolve exercises the api/catalog/cache getters ' + 'and degrades to networkError when the network is unavailable', () async { + await container + .read(connectorAccountsRepositoryProvider) + .create( + connectorType: lightroomConnectorType, + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + ); + container.invalidate(lightroomAccountProvider); + await container.read(lightroomAccountProvider.future); + + final resolver = container.read(connectorMediaResolverProvider); + expect(resolver.canResolveOnThisDevice(connectorItem()), isTrue); + + // Widget-test HTTP always fails (400) and the auth store is empty, so + // the rendition fetch cannot succeed -- what matters is that the + // provider-supplied getters run and the failure maps to a graceful + // UnavailableData, never a throw. + final data = await resolver.resolve(connectorItem()); + expect(data, isA()); + }); } diff --git a/test/features/media/presentation/lightroom_scan_helper_test.dart b/test/features/media/presentation/lightroom_scan_helper_test.dart index a79cc6ba8..7834b8e51 100644 --- a/test/features/media/presentation/lightroom_scan_helper_test.dart +++ b/test/features/media/presentation/lightroom_scan_helper_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; @@ -27,6 +28,7 @@ class _FakeScanService extends LightroomScanService { ); int scanCalls = 0; + Object? throwOnScan; @override Future scanDives({ @@ -35,6 +37,7 @@ class _FakeScanService extends LightroomScanService { required LightroomConnectorState state, }) async { scanCalls++; + if (throwOnScan != null) throw throwOnScan!; return LightroomScanSummary() ..attached = 3 ..suggested = 1 @@ -110,4 +113,21 @@ void main() { expect(service.scanCalls, 0); expect(find.byType(SnackBar), findsNothing); }); + + testWidgets('a failed scan shows the error and records it as the ' + 'connector last-error', (tester) async { + final (service, _) = await pump(tester, withAccount: account); + service.throwOnScan = const CloudStorageException('Adobe said no'); + + await tester.tap(find.text('scan')); + await tester.pumpAndSettle(); + + expect(find.text('Adobe said no'), findsOneWidget); + final prefs = await SharedPreferences.getInstance(); + final lastError = await LightroomConnectorState( + prefs: prefs, + accountId: account.id, + ).lastError(); + expect(lastError, 'Adobe said no'); + }); } diff --git a/test/features/media/presentation/pages/photo_viewer_lightroom_test.dart b/test/features/media/presentation/pages/photo_viewer_lightroom_test.dart new file mode 100644 index 000000000..d61225ede --- /dev/null +++ b/test/features/media/presentation/pages/photo_viewer_lightroom_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/presentation/pages/photo_viewer_page.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/media/presentation/providers/media_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + final account = domain.ConnectorAccount( + id: 'acct1', + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + addedAt: DateTime.utc(2026, 7, 1), + ); + + MediaItem item({ + MediaSourceType sourceType = MediaSourceType.serviceConnector, + }) => MediaItem( + id: 'm1', + diveId: 'd1', + mediaType: MediaType.photo, + sourceType: sourceType, + remoteAssetId: sourceType == MediaSourceType.serviceConnector + ? 'lr1' + : null, + platformAssetId: sourceType == MediaSourceType.platformGallery + ? 'g1' + : null, + takenAt: DateTime.utc(2026, 7, 1, 10), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ); + + Future pump( + WidgetTester tester, { + required MediaItem media, + domain.ConnectorAccount? withAccount, + }) async { + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomAccountProvider.overrideWith((ref) async => withAccount), + mediaForDiveProvider('d1').overrideWith((ref) async => [media]), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PhotoViewerPage(diveId: 'd1', initialMediaId: 'm1'), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pump(); + }); + } + + testWidgets('shows Open in Lightroom for a connector item on the ' + 'connected device', (tester) async { + await pump(tester, media: item(), withAccount: account); + expect(find.byTooltip('Open in Lightroom'), findsOneWidget); + }); + + testWidgets('hides Open in Lightroom without a connected account', ( + tester, + ) async { + await pump(tester, media: item()); + expect(find.byTooltip('Open in Lightroom'), findsNothing); + }); + + testWidgets('hides Open in Lightroom for non-connector items', ( + tester, + ) async { + await pump( + tester, + media: item(sourceType: MediaSourceType.platformGallery), + withAccount: account, + ); + expect(find.byTooltip('Open in Lightroom'), findsNothing); + }); +} diff --git a/test/features/media/presentation/widgets/dive_media_section_lightroom_test.dart b/test/features/media/presentation/widgets/dive_media_section_lightroom_test.dart new file mode 100644 index 000000000..60a087035 --- /dev/null +++ b/test/features/media/presentation/widgets/dive_media_section_lightroom_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/media/domain/entities/connector_account.dart' + as domain; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/dive_media_section.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + final account = domain.ConnectorAccount( + id: 'acct1', + connectorType: 'lightroom', + displayName: 'Eric', + credentialsRef: 'lightroom_auth', + accountIdentifier: 'cat1', + addedAt: DateTime.utc(2026, 7, 1), + ); + + Future pump( + WidgetTester tester, { + domain.ConnectorAccount? withAccount, + }) async { + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomAccountProvider.overrideWith((ref) async => withAccount), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: DiveMediaSection(diveId: 'd-none'), + ), + ), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + } + + testWidgets('shows the Lightroom scan action when connected and taps it ' + 'safely for a missing dive', (tester) async { + await pump(tester, withAccount: account); + + final button = find.byTooltip('Scan Lightroom'); + expect(button, findsOneWidget); + + // The dive id does not exist: _scanLightroom loads null and returns + // without scanning or crashing. + await tester.runAsync(() async { + await tester.tap(button); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + expect(tester.takeException(), isNull); + }); + + testWidgets('hides the Lightroom scan action when not connected', ( + tester, + ) async { + await pump(tester); + expect(find.byTooltip('Scan Lightroom'), findsNothing); + }); +} diff --git a/test/features/settings/presentation/pages/lightroom_settings_page_test.dart b/test/features/settings/presentation/pages/lightroom_settings_page_test.dart new file mode 100644 index 000000000..73a4f8c98 --- /dev/null +++ b/test/features/settings/presentation/pages/lightroom_settings_page_test.dart @@ -0,0 +1,399 @@ +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:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/services/lightroom/adobe_ims_auth_manager.dart'; +import 'package:submersion/core/services/lightroom/lightroom_api_client.dart'; +import 'package:submersion/core/services/lightroom/lightroom_auth_store.dart'; +import 'package:submersion/features/media/data/repositories/connector_accounts_repository.dart'; +import 'package:submersion/features/media/data/services/lightroom_connector_state.dart'; +import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; +import 'package:submersion/features/settings/presentation/pages/lightroom_settings_page.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; +import '../../../../support/fake_keychain_storage.dart'; + +const _guard = 'while (1) {}'; + +void main() { + late SharedPreferences prefs; + late AdobeImsAuthManager authManager; + + MockClient apiMock() => MockClient((request) async { + if (request.url.host == 'ims-na1.adobelogin.com') { + return http.Response( + jsonEncode({ + 'access_token': 'at', + 'refresh_token': 'rt', + 'expires_in': 3600, + }), + 200, + ); + } + if (request.url.path == '/v2/account') { + return http.Response( + '$_guard${jsonEncode({'id': 'acc1', 'full_name': 'Eric G', 'email': 'e@g.c'})}', + 200, + ); + } + if (request.url.path == '/v2/catalog') { + return http.Response('$_guard${jsonEncode({'id': 'cat9'})}', 200); + } + if (request.url.path.endsWith('/albums')) { + return http.Response( + _guard + + jsonEncode({ + 'resources': [ + { + 'id': 'al1', + 'payload': {'name': 'Diving'}, + }, + { + 'id': 'al2', + 'payload': {'name': 'Wrecks'}, + }, + ], + }), + 200, + ); + } + return http.Response('not found', 404); + }); + + setUp(() async { + await setUpTestDatabase(); + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + authManager = AdobeImsAuthManager( + store: LightroomAuthStore(storage: InMemoryKeychain()), + httpClient: apiMock(), + ); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Widget app() => ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomAuthManagerProvider.overrideWithValue(authManager), + lightroomApiClientProvider.overrideWithValue( + LightroomApiClient(auth: authManager, httpClient: apiMock()), + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: LightroomSettingsPage(), + ), + ); + + Future pumpPage(WidgetTester tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(app()); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + } + + Future settle(WidgetTester tester) async { + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + } + + group('disconnected', () { + testWidgets('renders credential fields with connect disabled until a ' + 'client id is entered', (tester) async { + await pumpPage(tester); + + expect(find.text('Adobe client ID'), findsOneWidget); + expect(find.text('Client secret (optional)'), findsOneWidget); + final button = tester.widget( + find.widgetWithText(FilledButton, 'Connect Lightroom'), + ); + expect(button.onPressed, isNull); + + await tester.enterText( + find.widgetWithText(TextField, 'Adobe client ID'), + 'cid', + ); + await tester.pump(); + final enabled = tester.widget( + find.widgetWithText(FilledButton, 'Connect Lightroom'), + ); + expect(enabled.onPressed, isNotNull); + }); + + testWidgets('full connect flow creates the account and shows the ' + 'connected body', (tester) async { + await pumpPage(tester); + + await tester.enterText( + find.widgetWithText(TextField, 'Adobe client ID'), + 'cid', + ); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, 'Connect Lightroom')); + await settle(tester); + + // The connect dialog is open (the browser open fails in tests and + // surfaces an error inline, which must not block the flow). + expect(find.text('Connect Lightroom'), findsWidgets); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + 'https://submersion.app/lightroom/callback?code=thecode', + ); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.widgetWithText(FilledButton, 'Connect'), + ), + ); + await settle(tester); + await settle(tester); + + expect(find.text('Connected as Eric G'), findsOneWidget); + final auth = await tester.runAsync(() => authManager.loadAuth()); + expect(auth!.catalogId, 'cat9'); + expect(auth.displayName, 'Eric G'); + }); + }); + + group('disconnected failure', () { + testWidgets('a failing account fetch after the dialog surfaces the ' + 'error and stays disconnected', (tester) async { + // Token exchange succeeds; every lr.adobe.io call fails. + final failingApi = MockClient((request) async { + if (request.url.host == 'ims-na1.adobelogin.com') { + return http.Response( + jsonEncode({ + 'access_token': 'at', + 'refresh_token': 'rt', + 'expires_in': 3600, + }), + 200, + ); + } + return http.Response('down', 500); + }); + authManager = AdobeImsAuthManager( + store: LightroomAuthStore(storage: InMemoryKeychain()), + httpClient: failingApi, + ); + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + lightroomAuthManagerProvider.overrideWithValue(authManager), + lightroomApiClientProvider.overrideWithValue( + LightroomApiClient(auth: authManager, httpClient: failingApi), + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: LightroomSettingsPage(), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + + await tester.enterText( + find.widgetWithText(TextField, 'Adobe client ID'), + 'cid', + ); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, 'Connect Lightroom')); + await settle(tester); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + 'thecode', + ); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.widgetWithText(FilledButton, 'Connect'), + ), + ); + await settle(tester); + await settle(tester); + + expect( + find.textContaining('Could not connect to Lightroom'), + findsOneWidget, + ); + expect(find.text('Adobe client ID'), findsOneWidget); + }); + }); + + group('connected', () { + late String accountId; + + Future seedAccount() async { + // The API client resolves credentials through the auth manager, so + // the connected state needs both the account row AND the auth blob. + await authManager.updateAuth( + const LightroomAuthData(clientId: 'cid', refreshToken: 'rt'), + ); + final account = await ConnectorAccountsRepository().create( + connectorType: lightroomConnectorType, + displayName: 'Eric G', + credentialsRef: LightroomAuthStore.storageKey, + accountIdentifier: 'cat9', + ); + accountId = account.id; + } + + testWidgets('shows account, album filter, auto-poll, scan, disconnect', ( + tester, + ) async { + await tester.runAsync(seedAccount); + await pumpPage(tester); + + expect(find.text('Connected as Eric G'), findsOneWidget); + expect(find.text('Albums to scan'), findsOneWidget); + expect(find.text('Entire catalog'), findsOneWidget); + expect(find.text('Check for new photos automatically'), findsOneWidget); + expect(find.text('Scan Lightroom'), findsOneWidget); + expect(find.text('Disconnect'), findsOneWidget); + }); + + testWidgets('shows the last poll time when one is recorded', ( + tester, + ) async { + await tester.runAsync(() async { + await seedAccount(); + await LightroomConnectorState( + prefs: prefs, + accountId: accountId, + ).setLastPollAt(DateTime.utc(2026, 7, 10, 6)); + }); + await pumpPage(tester); + await settle(tester); + + expect(find.textContaining('Last checked:'), findsOneWidget); + }); + + testWidgets('needs-reauth chip appears when a last error is recorded', ( + tester, + ) async { + await tester.runAsync(() async { + await seedAccount(); + await LightroomConnectorState( + prefs: prefs, + accountId: accountId, + ).setLastError('401'); + }); + await pumpPage(tester); + await settle(tester); + + expect(find.text('Reconnect needed'), findsOneWidget); + }); + + testWidgets('auto-poll toggle persists', (tester) async { + await tester.runAsync(seedAccount); + await pumpPage(tester); + await settle(tester); + + await tester.tap(find.byType(SwitchListTile)); + await settle(tester); + + final enabled = await tester.runAsync( + () => LightroomConnectorState( + prefs: prefs, + accountId: accountId, + ).autoPollEnabled(), + ); + expect(enabled, isFalse); + }); + + testWidgets('album filter dialog lists albums and persists the ' + 'selection', (tester) async { + await tester.runAsync(seedAccount); + await pumpPage(tester); + await settle(tester); + + await tester.tap(find.text('Albums to scan')); + await settle(tester); + await settle(tester); + + expect(find.text('Diving'), findsOneWidget); + expect(find.text('Wrecks'), findsOneWidget); + await tester.tap(find.text('Diving')); + await tester.pump(); + await tester.tap(find.text('OK')); + await settle(tester); + + final albumIds = await tester.runAsync( + () => LightroomConnectorState( + prefs: prefs, + accountId: accountId, + ).albumIds(), + ); + expect(albumIds, ['al1']); + }); + + testWidgets('scan now runs and reports an empty summary', (tester) async { + await tester.runAsync(seedAccount); + await pumpPage(tester); + await settle(tester); + + await tester.tap(find.text('Scan Lightroom')); + await settle(tester); + await settle(tester); + + // No dives in the database: the scan short-circuits with all-zero + // counters and reports through the summary snackbar. + expect( + find.text('0 linked, 0 suggested, 0 already linked'), + findsOneWidget, + ); + }); + + testWidgets('disconnect confirm removes the account and returns to the ' + 'credential form', (tester) async { + await tester.runAsync(seedAccount); + await pumpPage(tester); + await settle(tester); + + await tester.tap(find.text('Disconnect')); + await settle(tester); + expect(find.text('Disconnect Lightroom?'), findsOneWidget); + + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.widgetWithText(FilledButton, 'Disconnect'), + ), + ); + await settle(tester); + await settle(tester); + + expect(find.text('Adobe client ID'), findsOneWidget); + final account = await tester.runAsync( + () => ConnectorAccountsRepository().getByType(lightroomConnectorType), + ); + expect(account, isNull); + }); + }); +} diff --git a/test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart b/test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart index d92aa0912..d02e0951f 100644 --- a/test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart +++ b/test/features/settings/presentation/widgets/lightroom_connect_dialog_test.dart @@ -123,4 +123,129 @@ void main() { findsOneWidget, ); }); + + testWidgets('a failed browser open shows the browser-failed message and ' + 'Reopen browser retries with the same URL', (tester) async { + final opened = []; + var openResult = false; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => LightroomConnectDialog( + authManager: manager(happyMock()), + clientId: 'cid', + openUri: (uri) async { + opened.add(uri); + return openResult; + }, + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.text('Could not open your browser. Try the Reopen browser button.'), + findsOneWidget, + ); + + openResult = true; + await tester.tap(find.text('Reopen browser')); + await tester.pumpAndSettle(); + + expect(opened, hasLength(2)); + expect(opened[0], opened[1], reason: 'same verifier, same URL'); + expect( + find.text('Could not open your browser. Try the Reopen browser button.'), + findsNothing, + ); + }); + + testWidgets('an empty client id surfaces the auth error inline', ( + tester, + ) async { + final opened = []; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => LightroomConnectDialog( + authManager: manager(happyMock()), + clientId: '', + openUri: (uri) async { + opened.add(uri); + return true; + }, + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(opened, isEmpty); + expect( + find.text('Enter your Adobe client ID before connecting.'), + findsOneWidget, + ); + }); + + testWidgets('a raw exception during the exchange is caught and shown', ( + tester, + ) async { + await pumpDialog(tester, _ThrowingAuthManager()); + await tester.enterText(find.byType(TextField), 'code'); + await tester.tap(find.text('Connect')); + await tester.pumpAndSettle(); + + expect(find.byType(LightroomConnectDialog), findsOneWidget); + expect(find.textContaining('keychain exploded'), findsOneWidget); + }); +} + +/// Models the raw (non-CloudStorageException) failure path: the final +/// store save can throw a keychain PlatformException. +class _ThrowingAuthManager extends AdobeImsAuthManager { + _ThrowingAuthManager() + : super( + store: LightroomAuthStore(storage: InMemoryKeychain()), + verifierGenerator: () => 'a' * 43, + ); + + @override + Future completeAuthorization( + String codeOrRedirectUrl, + ) async { + throw StateError('keychain exploded'); + } } diff --git a/test/features/trips/presentation/widgets/trip_photo_section_lightroom_test.dart b/test/features/trips/presentation/widgets/trip_photo_section_lightroom_test.dart new file mode 100644 index 000000000..df3c5be56 --- /dev/null +++ b/test/features/trips/presentation/widgets/trip_photo_section_lightroom_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/trips/presentation/widgets/trip_photo_section.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + setUp(setUpTestDatabase); + tearDown(tearDownTestDatabase); + + Future pump( + WidgetTester tester, { + VoidCallback? onLightroomScanPressed, + }) async { + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TripPhotoSection( + tripId: 't1', + onScanPressed: () {}, + onLightroomScanPressed: onLightroomScanPressed, + ), + ), + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + } + + testWidgets('shows the Lightroom scan button only when a handler is ' + 'provided, and taps reach it', (tester) async { + var taps = 0; + await pump(tester, onLightroomScanPressed: () => taps++); + + final button = find.byTooltip('Scan Lightroom'); + expect(button, findsOneWidget); + await tester.tap(button); + expect(taps, 1); + }); + + testWidgets('hides the Lightroom scan button without a handler', ( + tester, + ) async { + await pump(tester); + expect(find.byTooltip('Scan Lightroom'), findsNothing); + }); +}