fix(profiles): remount profile session

close #1371
This commit is contained in:
edde746
2026-06-18 00:53:48 +02:00
parent 66b80a33ba
commit 6f39a286d3
35 changed files with 691 additions and 179 deletions
+223
View File
@@ -0,0 +1,223 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../focus/key_event_utils.dart';
import '../media/ids.dart';
import '../media/media_server_client.dart';
import '../profiles/active_profile_provider.dart';
import '../providers/companion_remote_provider.dart';
import '../providers/discover_provider.dart';
import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../providers/trakt_account_provider.dart';
import '../providers/trackers_provider.dart';
import '../providers/watch_state_store.dart';
import '../screens/main_screen.dart';
import '../services/storage_service.dart';
import '../utils/app_logger.dart';
import '../watch_together/providers/watch_together_provider.dart';
import 'profile_navigation_scope.dart';
/// Root route for an active profile session.
///
/// The root app navigator owns setup/auth/profile-picking. This route owns the
/// profile-scoped provider tree and a nested navigator for all content routes.
/// Changing the active profile changes the keyed boundary below, disposing the
/// old nested navigator, MainScreen, tab state, and profile-scoped providers.
///
/// Keep profile-owned routes, dialogs, sheets, and virtual keyboards on the
/// nearest navigator from this subtree. Keep setup/auth/PIN/profile-picker flows
/// on the root navigator so they survive this subtree being replaced.
class ProfileSessionScreen extends StatefulWidget {
const ProfileSessionScreen({super.key, this.isOfflineMode = false, this.initialPromptHandled = false})
: profileShellBuilder = null;
@visibleForTesting
const ProfileSessionScreen.forTesting({
super.key,
this.isOfflineMode = false,
this.initialPromptHandled = false,
required this.profileShellBuilder,
});
final bool isOfflineMode;
final bool initialPromptHandled;
final WidgetBuilder? profileShellBuilder;
@override
State<ProfileSessionScreen> createState() => _ProfileSessionScreenState();
}
class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
// Profile changes remount the inner session, but the root route survives.
// Treat the initial launch/profile prompt as handled after the first session
// frame so switching profiles from the root picker does not immediately open
// another required-selection picker underneath it. Flipped via a post-frame
// callback rather than during build to avoid mutating state mid-build.
bool _hasBuiltSession = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _hasBuiltSession = true;
});
}
@override
Widget build(BuildContext context) {
return Consumer<ActiveProfileProvider>(
builder: (context, activeProfile, _) {
final activeId = activeProfile.activeId;
final initialPromptHandled = widget.initialPromptHandled || _hasBuiltSession;
return KeyedSubtree(
key: ValueKey<String?>('profile-session:$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;
},
),
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) => LibrariesProvider(
storageService: context.read<StorageService>(),
multiServer: context.read<MultiServerProvider>(),
),
),
ChangeNotifierProvider(
create: (context) {
final activeProfile = context.read<ActiveProfileProvider>();
return DiscoverProvider(
context.read<MultiServerProvider>(),
context.read<HiddenLibrariesProvider>(),
context.read<LibrariesProvider>(),
isProfileBinding: () => activeProfile.isBinding,
);
},
),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()),
],
child: _ProfileSessionNavigator(
isOfflineMode: widget.isOfflineMode,
initialPromptHandled: initialPromptHandled,
profileShellBuilder: widget.profileShellBuilder,
),
),
);
},
);
}
}
class _ProfileSessionNavigator extends StatefulWidget {
const _ProfileSessionNavigator({
required this.isOfflineMode,
required this.initialPromptHandled,
required this.profileShellBuilder,
});
final bool isOfflineMode;
final bool initialPromptHandled;
final WidgetBuilder? profileShellBuilder;
@override
State<_ProfileSessionNavigator> createState() => _ProfileSessionNavigatorState();
}
class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> {
final _navigatorKey = GlobalKey<NavigatorState>();
final _mainScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final _routeObserver = RouteObserver<PageRoute<dynamic>>();
@override
void initState() {
super.initState();
profileNavigationRegistry.attachNavigator(_navigatorKey);
profileNavigationRegistry.attachMainScaffoldMessenger(_mainScaffoldMessengerKey);
}
@override
void dispose() {
profileNavigationRegistry.detachNavigator(_navigatorKey);
profileNavigationRegistry.detachMainScaffoldMessenger(_mainScaffoldMessengerKey);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ProfileNavigationScope(
navigatorKey: _navigatorKey,
routeObserver: _routeObserver,
mainScaffoldMessengerKey: _mainScaffoldMessengerKey,
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
unawaited(_navigatorKey.currentState?.maybePop());
},
child: Navigator(
key: _navigatorKey,
observers: [_routeObserver, BackKeySuppressorObserver()],
onGenerateRoute: _onGenerateRoute,
),
),
);
}
Route<dynamic> _onGenerateRoute(RouteSettings settings) {
// This navigator's initial route is the profile shell. Content routes are
// pushed imperatively from inside the shell, so named routes belong to the
// root navigator unless this method is expanded intentionally.
final routeName = settings.name;
if (routeName != null && routeName != Navigator.defaultRouteName) {
throw FlutterError('ProfileSessionNavigator does not handle named route "$routeName".');
}
return MaterialPageRoute<void>(
settings: settings,
builder: (context) =>
widget.profileShellBuilder?.call(context) ??
MainScreen(isOfflineMode: widget.isOfflineMode, initialPromptHandled: widget.initialPromptHandled),
);
}
}