refactor(profiles): profile-scoped provider subtree keyed by active profile

This commit is contained in:
edde746
2026-06-12 14:44:10 +02:00
parent 5047057521
commit 49ff0854c0
2 changed files with 106 additions and 118 deletions
+105 -116
View File
@@ -496,6 +496,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
// Trakt sync service (subscribes to WatchStateNotifier, requires serverManager
// to resolve PlexClients for GUID lookups).
TraktSyncService.instance.initialize(serverManager: _serverManager);
// Tracker singletons init once per app; per-profile hydration happens in
// the profile-scoped provider subtree's create callbacks.
unawaited(TrackerCoordinator.instance.initialize());
_appLifecycleListener = AppLifecycleListener(
onExitRequested: () async {
@@ -772,18 +775,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider;
},
),
ChangeNotifierProxyProvider2<ActiveProfileProvider, MultiServerProvider, WatchStateStore>(
create: (_) => WatchStateStore(),
update: (_, activeProfile, multiServer, previous) {
final provider = previous ?? WatchStateStore();
provider.setActiveProfileId(activeProfile.activeId);
provider.setActiveClientScopesByServer({
for (final serverId in multiServer.serverManager.serverIds)
serverId: multiServer.serverManager.getClient(ServerId(serverId))?.cacheServerId,
});
return provider;
},
),
ChangeNotifierProxyProvider<ActiveProfileProvider, OfflineWatchSyncService>(
create: (context) {
final offlineModeProvider = context.read<OfflineModeProvider>();
@@ -856,41 +847,104 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
},
),
ChangeNotifierProvider(create: (context) => ThemeProvider()),
// Tracker accounts — depend on UserProfileProvider for per-profile
// session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`.
ChangeNotifierProvider(create: (context) => TraktAccountProvider()),
ChangeNotifierProvider(create: (context) => TrackersProvider()),
ChangeNotifierProvider(
create: (context) => HiddenLibrariesProvider(storageService: context.read<StorageService>()),
lazy: true,
),
ChangeNotifierProvider(
create: (context) {
final provider = LibrariesProvider(storageService: context.read<StorageService>());
// Reload libraries when a new server comes online. Servers bind in
// waves on sign-in / profile switch and slow ones reconnect after
// the initial load; without this they stay missing from the sidebar
// until a profile re-switch or restart.
context.read<MultiServerProvider>().onOnlineServersChanged = provider.syncToOnlineServers;
return provider;
},
),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()),
// Shader presets are app-global — deliberately outside the
// profile-scoped subtree below.
ChangeNotifierProvider(create: (context) => ShaderProvider()),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return TranslationProvider(
child: Builder(
builder: (context) {
final trakt = context.read<TraktAccountProvider>();
final trackers = context.read<TrackersProvider>();
return _TrackerProfileBootstrap(
onProfileChanged: [trakt.onActiveProfileChanged, trackers.onActiveProfileChanged],
onFirstMount: TrackerCoordinator.instance.initialize,
child: Listener(
// Profile boundary: everything below is profile-scoped BY TREE
// POSITION. Switching the active profile changes the KeyedSubtree key,
// which disposes and recreates the inner providers and the whole app
// shell — including navigation, so routes showing the previous
// profile's content cannot survive into the next. A provider belongs in
// the inner list when its state is per-profile; app-global state
// (theme, shader presets, downloads, server manager) stays above.
// Shared singletons that span profiles (DownloadProvider's sync rules,
// OfflineWatchSyncService) keep their explicit setActiveProfileId
// wiring instead.
child: Consumer<ActiveProfileProvider>(
builder: (context, activeProfile, _) {
final activeId = activeProfile.activeId;
return KeyedSubtree(
key: ValueKey<String?>(activeId),
child: MultiProvider(
providers: [
ChangeNotifierProxyProvider<MultiServerProvider, WatchStateStore>(
create: (_) => WatchStateStore(),
update: (_, multiServer, previous) {
final provider = previous ?? WatchStateStore();
provider.setActiveProfileId(activeId);
provider.setActiveClientScopesByServer({
for (final serverId in multiServer.serverManager.serverIds)
serverId: multiServer.serverManager.getClient(ServerId(serverId))?.cacheServerId,
});
return provider;
},
),
// Tracker accounts hydrate for the active profile on create —
// the subtree remount on profile switch is the rebind.
ChangeNotifierProvider(
create: (context) {
final provider = TraktAccountProvider();
unawaited(
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
appLogger.w('Trakt profile hydrate failed', error: e, stackTrace: s);
}),
);
return provider;
},
),
ChangeNotifierProvider(
create: (context) {
final provider = TrackersProvider();
unawaited(
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
appLogger.w('Trackers profile hydrate failed', error: e, stackTrace: s);
}),
);
return provider;
},
),
ChangeNotifierProvider(
create: (context) => HiddenLibrariesProvider(storageService: context.read<StorageService>()),
lazy: true,
),
ChangeNotifierProvider(
create: (context) {
final provider = LibrariesProvider(storageService: context.read<StorageService>());
// Reload libraries when a new server comes online. Servers
// bind in waves on sign-in / profile switch and slow ones
// reconnect after the initial load; without this they stay
// missing from the sidebar until a re-switch or restart.
context.read<MultiServerProvider>().onOnlineServersChanged = provider.syncToOnlineServers;
return provider;
},
),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()),
],
child: const _AppShell(),
),
);
},
),
);
}
}
/// The app shell below the profile boundary: theme consumer, translations,
/// global input handling, and the MaterialApp.
class _AppShell extends StatelessWidget {
const _AppShell();
@override
Widget build(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return TranslationProvider(
child: Builder(
builder: (context) {
return Listener(
onPointerDown: (event) {
if ((event.buttons & kBackMouseButton) != 0) {
rootNavigatorKey.currentState?.maybePop();
@@ -930,14 +984,12 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
),
),
),
),
);
},
),
);
},
),
);
);
},
),
);
},
);
}
}
@@ -990,69 +1042,6 @@ class _AppleTvScale extends StatelessWidget {
}
}
/// Hydrates Trakt and MAL/AniList/Simkl providers with the active profile's
/// sessions and rebinds their services whenever the user switches profiles.
///
/// Lives high in the widget tree (above MaterialApp) so the listener survives
/// route changes. [onFirstMount] runs exactly once after the first
/// `didChangeDependencies`.
class _TrackerProfileBootstrap extends StatefulWidget {
final Widget child;
final List<Future<void> Function(String? profileId)> onProfileChanged;
final VoidCallback? onFirstMount;
const _TrackerProfileBootstrap({required this.child, required this.onProfileChanged, this.onFirstMount});
@override
State<_TrackerProfileBootstrap> createState() => _TrackerProfileBootstrapState();
}
class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> {
ActiveProfileProvider? _provider;
String? _lastId;
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final provider = context.read<ActiveProfileProvider>();
if (!identical(_provider, provider)) {
_provider?.removeListener(_onProfileChanged);
_provider = provider;
_provider!.addListener(_onProfileChanged);
}
if (!_initialized) {
_initialized = true;
widget.onFirstMount?.call();
_onProfileChanged();
}
}
void _onProfileChanged() {
final id = _provider?.activeId;
if (id == _lastId) return;
_lastId = id;
for (final fn in widget.onProfileChanged) {
unawaited(
fn(id).catchError((Object e, StackTrace s) {
appLogger.w('Tracker profile bootstrap failed', error: e, stackTrace: s);
}),
);
}
}
@override
void dispose() {
_provider?.removeListener(_onProfileChanged);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
class OrientationAwareSetup extends StatefulWidget {
const OrientationAwareSetup({super.key});
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
@@ -179,7 +178,7 @@ void main() {
final userData = await readBack('user-a');
expect(userData.containsKey('UnplayedItemCount'), isFalse);
expect(jsonEncode(userData), isNot(contains('9')));
expect(userData.values, isNot(contains(9)));
});
test('malformed cached rows are skipped without throwing', () async {