refactor: shared coalescer, profile-scoped prefs key, and form-error helper

This commit is contained in:
edde746
2026-07-10 07:05:35 +02:00
parent 397de2698d
commit ede80d225f
9 changed files with 119 additions and 28 deletions
+17
View File
@@ -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) {
+2 -12
View File
@@ -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),
],
),
),
+2 -2
View File
@@ -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);
});
});
}