refactor: shared coalescer, profile-scoped prefs key, and form-error helper
This commit is contained in:
@@ -223,6 +223,23 @@ final RegExp _trailingHomeUserUuidPattern = RegExp(
|
||||
r'-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16})$',
|
||||
);
|
||||
|
||||
/// Prefs scope for a profile id: Plex Home profiles scope by their bare
|
||||
/// home-user uuid (`user_{uuid}_*`), other profiles by the full id.
|
||||
///
|
||||
/// Every `user_{scope}_{key}` prefs-key builder must apply this —
|
||||
/// [StorageService]'s `_migratePlexHomeUserScopes` relocates full-profile-id
|
||||
/// keys onto the uuid scope at every launch, so a builder that skips the
|
||||
/// normalization writes keys that vanish on the next restart (Trakt
|
||||
/// "unlinking" on hot restart was exactly this).
|
||||
String profileUserScope(String profileId) => parsePlexHomeProfileId(profileId)?.homeUserUuid ?? profileId;
|
||||
|
||||
/// THE builder for profile-scoped prefs keys: `user_{scope}_{baseKey}`, or
|
||||
/// the bare [baseKey] for the empty (signed-out/account-level) scope.
|
||||
/// Pairs the `user_` prefix with [profileUserScope] in one place so no key
|
||||
/// builder can skip the normalization (see the warning above).
|
||||
String profileScopedPrefsKey(String userUuid, String baseKey) =>
|
||||
userUuid.isEmpty ? baseKey : 'user_${profileUserScope(userUuid)}_$baseKey';
|
||||
|
||||
/// Inverse of [plexHomeProfileId]. Returns `null` if [id] doesn't match the
|
||||
/// `plex-home-{accountConnectionId}-{homeUserUuid}` shape.
|
||||
({String accountConnectionId, String homeUserUuid})? parsePlexHomeProfileId(String id) {
|
||||
|
||||
@@ -574,10 +574,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
),
|
||||
],
|
||||
],
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(errorText!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)),
|
||||
],
|
||||
...buildInlineError(theme),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -738,14 +735,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
label: Text(t.auth.quickConnectCancel),
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
errorText!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
...buildInlineError(theme, gap: 16, center: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -175,14 +175,7 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
|
||||
],
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
...buildInlineError(theme, gap: 16, center: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
@@ -34,6 +34,19 @@ mixin AsyncFormStateMixin<T extends StatefulWidget> on State<T> {
|
||||
setState(() => _busy = value);
|
||||
}
|
||||
|
||||
/// Inline error text under a form. Spread into a children list:
|
||||
/// `...buildInlineError(theme)`.
|
||||
List<Widget> buildInlineError(ThemeData theme, {double gap = 12, bool center = false}) => [
|
||||
if (errorText != null) ...[
|
||||
SizedBox(height: gap),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
textAlign: center ? TextAlign.center : null,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
/// Set the error text directly (e.g. for synchronous validation failures
|
||||
/// or post-success rejections like a duplicate-account guard).
|
||||
void setErrorText(String? value) {
|
||||
|
||||
@@ -124,10 +124,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
label: Text(t.common.save),
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(errorText!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)),
|
||||
],
|
||||
...buildInlineError(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -96,12 +96,12 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
/// full profile id is the scope.
|
||||
String? activeUserScope() => _activeUserScope();
|
||||
|
||||
String userScopeForProfileId(String profileId) => parsePlexHomeProfileId(profileId)?.homeUserUuid ?? profileId;
|
||||
String userScopeForProfileId(String profileId) => profileUserScope(profileId);
|
||||
|
||||
String? _activeUserScope() {
|
||||
final id = getActiveProfileId();
|
||||
if (id == null) return null;
|
||||
return parsePlexHomeProfileId(id)?.homeUserUuid ?? id;
|
||||
return profileUserScope(id);
|
||||
}
|
||||
|
||||
/// Returns `'user_{scope}_'` for the active profile, or `''` if no
|
||||
|
||||
@@ -12,4 +12,30 @@ class FutureCoalescer<T> {
|
||||
_inFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
/// Detach the in-flight future (it keeps running, but the next [run]
|
||||
/// starts fresh instead of joining it). The identical-guard above keeps
|
||||
/// the detached future's completion from clearing a newer slot.
|
||||
void reset() {
|
||||
_inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyed [FutureCoalescer]: one in-flight future per key. Used for the
|
||||
/// static per-identity re-auth/refresh maps (Trakt refresh-by-token, Seerr
|
||||
/// re-auth-by-instance) so concurrent 401s trigger one login each.
|
||||
class KeyedFutureCoalescer<K, T> {
|
||||
final Map<K, Future<T>> _inFlight = {};
|
||||
|
||||
Future<T> run(K key, Future<T> Function() create) {
|
||||
final existing = _inFlight[key];
|
||||
if (existing != null) return existing;
|
||||
|
||||
late final Future<T> future;
|
||||
future = create().whenComplete(() {
|
||||
if (identical(_inFlight[key], future)) _inFlight.remove(key);
|
||||
});
|
||||
_inFlight[key] = future;
|
||||
return future;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../profiles/profile.dart';
|
||||
import '../base_shared_preferences_service.dart';
|
||||
import 'tracker_constants.dart';
|
||||
import 'tracker_session.dart';
|
||||
@@ -25,7 +26,7 @@ class TrackerAccountStore {
|
||||
|
||||
TrackerAccountStore._(this.service, this._baseKey);
|
||||
|
||||
String _scopedKey(String userUuid) => userUuid.isEmpty ? _baseKey : 'user_${userUuid}_$_baseKey';
|
||||
String _scopedKey(String userUuid) => profileScopedPrefsKey(userUuid, _baseKey);
|
||||
|
||||
Future<TrackerSession?> load(String userUuid) async {
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/trackers/tracker_account_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
|
||||
import '../../test_helpers/prefs.dart';
|
||||
|
||||
const _fullProfileId = 'plex-home-plex.e443d57860076fc3-e443d57860076fc3';
|
||||
const _homeUserUuid = 'e443d57860076fc3';
|
||||
|
||||
TrackerSession _session() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return TrackerSession(accessToken: 'a', refreshToken: 'r', expiresAt: now + 86400, createdAt: now);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
group('profile user scoping', () {
|
||||
test('profileUserScope reduces Plex Home ids to the bare home-user uuid', () {
|
||||
expect(profileUserScope(_fullProfileId), _homeUserUuid);
|
||||
expect(profileUserScope(_homeUserUuid), _homeUserUuid);
|
||||
expect(profileUserScope('local-abc'), 'local-abc');
|
||||
});
|
||||
|
||||
test('profileScopedPrefsKey normalizes full profile ids to the uuid scope', () {
|
||||
expect(profileScopedPrefsKey(_fullProfileId, 'trakt_session'), 'user_${_homeUserUuid}_trakt_session');
|
||||
expect(profileScopedPrefsKey(_homeUserUuid, 'trakt_session'), 'user_${_homeUserUuid}_trakt_session');
|
||||
expect(profileScopedPrefsKey('', 'trakt_session'), 'trakt_session');
|
||||
});
|
||||
|
||||
/// Regression: sessions saved under the full profile id were relocated to
|
||||
/// the uuid scope by StorageService's launch-time repair, so the next
|
||||
/// hydrate (same full id) missed them — Trakt "unlinked" on every
|
||||
/// restart. Save and load must agree on the uuid scope regardless of
|
||||
/// which id form the caller passes.
|
||||
test('store writes the uuid-scoped key and loads it from either id form', () async {
|
||||
final store = trackerAccountStore(TrackerService.trakt);
|
||||
await store.save(_fullProfileId, _session());
|
||||
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
expect(prefs.getString('user_${_homeUserUuid}_trakt_session'), isNotNull);
|
||||
expect(prefs.getString('user_${_fullProfileId}_trakt_session'), isNull);
|
||||
|
||||
expect(await store.load(_fullProfileId), isNotNull);
|
||||
expect(await store.load(_homeUserUuid), isNotNull);
|
||||
|
||||
await store.clear(_fullProfileId);
|
||||
expect(await store.load(_homeUserUuid), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user