diff --git a/lib/main.dart b/lib/main.dart index cadc49ce..520763f7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'media/ids.dart'; import 'dart:io' show Directory, Platform, ProcessInfo; import 'dart:ui' show AppExitResponse; import 'package:flutter/foundation.dart'; @@ -15,6 +14,8 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'connection/connection.dart'; import 'connection/connection_bootstrap.dart'; import 'connection/connection_registry.dart'; +import 'navigation/profile_navigation_scope.dart'; +import 'navigation/profile_session_screen.dart'; import 'profiles/active_profile_binder.dart'; import 'profiles/active_profile_provider.dart'; import 'profiles/profile.dart'; @@ -23,7 +24,6 @@ import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_registry.dart'; import 'mixins/mounted_set_state_mixin.dart'; import 'profiles/plex_home_service.dart'; -import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; import 'screens/profile/pin_entry_dialog.dart'; import 'screens/profile/profile_switch_screen.dart'; @@ -42,23 +42,14 @@ import 'services/gamepad_service.dart'; import 'services/trakt/trakt_scrobble_service.dart'; import 'services/trakt/trakt_sync_service.dart'; import 'services/trackers/tracker_coordinator.dart'; -import 'providers/trakt_account_provider.dart'; -import 'providers/trackers_provider.dart'; import 'providers/user_profile_provider.dart'; import 'providers/multi_server_provider.dart'; import 'providers/theme_provider.dart'; -import 'providers/hidden_libraries_provider.dart'; -import 'providers/libraries_provider.dart'; -import 'providers/discover_provider.dart'; -import 'providers/playback_state_provider.dart'; import 'providers/download_provider.dart'; import 'providers/offline_mode_provider.dart'; import 'providers/offline_watch_provider.dart'; -import 'providers/watch_state_store.dart'; -import 'providers/companion_remote_provider.dart'; import 'providers/shader_provider.dart'; import 'utils/snackbar_helper.dart'; -import 'watch_together/providers/watch_together_provider.dart'; import 'services/multi_server_manager.dart'; import 'services/offline_watch_sync_service.dart'; import 'services/data_aggregation_service.dart'; @@ -77,7 +68,6 @@ import 'utils/media_server_http_client.dart'; import 'utils/orientation_helper.dart'; import 'utils/watch_state_notifier.dart'; import 'i18n/strings.g.dart'; -import 'media/media_server_client.dart'; import 'focus/input_mode_tracker.dart'; import 'focus/key_event_utils.dart'; import 'package:intl/date_symbol_data_local.dart'; @@ -416,7 +406,6 @@ void _registerShaderLicenses() { }); } -final RouteObserver routeObserver = RouteObserver(); final rootNavigatorKey = GlobalKey(); @visibleForTesting @@ -425,10 +414,10 @@ bool shouldEnterOfflineModeAfterStartupBind({required bool bindingSucceeded, req } /// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the -/// per-screen widget tree. Routes through [rootNavigatorKey] so the dialog -/// renders correctly whether the binder fires from the splash, MainScreen, -/// or any future host. Returns `null` when no Navigator is available yet -/// (early boot, post-dispose) so the binder treats it as "PIN cancelled". +/// profile-scoped widget tree. Routes through the app-global +/// [rootNavigatorKey] so the dialog survives profile-session remounts. Returns +/// `null` when no Navigator is available yet (early boot, post-dispose) so the +/// binder treats it as "PIN cancelled". Future _rootPinPrompt(Profile profile, {String? errorMessage}) { final ctx = rootNavigatorKey.currentContext; if (ctx == null) return Future.value(null); @@ -857,98 +846,18 @@ class _MainAppState extends State with WidgetsBindingObserver { ), ChangeNotifierProvider(create: (context) => ThemeProvider()), // Shader presets are app-global — deliberately outside the - // profile-scoped subtree below. + // profile-scoped session in ProfileSessionScreen. ChangeNotifierProvider(create: (context) => ShaderProvider()), ], - // 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( - builder: (context, activeProfile, _) { - final activeId = activeProfile.activeId; - return KeyedSubtree( - key: ValueKey(activeId), - child: MultiProvider( - providers: [ - ChangeNotifierProxyProvider( - 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()), - lazy: true, - ), - ChangeNotifierProvider( - create: (context) => LibrariesProvider( - storageService: context.read(), - multiServer: context.read(), - ), - ), - ChangeNotifierProvider( - create: (context) { - final activeProfile = context.read(); - return DiscoverProvider( - context.read(), - context.read(), - context.read(), - isProfileBinding: () => activeProfile.isBinding, - ); - }, - ), - ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), - ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), - ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()), - ], - child: const _AppShell(), - ), - ); - }, - ), + child: const _AppShell(), ); } } -/// The app shell below the profile boundary: theme consumer, translations, -/// global input handling, and the MaterialApp. +/// App-global shell: theme consumer, translations, root input handling, and the +/// root MaterialApp. Profile-scoped providers/navigation live in +/// [ProfileSessionScreen], not here, so root auth/PIN/global dialogs survive a +/// profile switch. class _AppShell extends StatelessWidget { const _AppShell(); @@ -962,7 +871,14 @@ class _AppShell extends StatelessWidget { return Listener( onPointerDown: (event) { if ((event.buttons & kBackMouseButton) != 0) { - rootNavigatorKey.currentState?.maybePop(); + unawaited(() async { + final rootNavigator = rootNavigatorKey.currentState; + if (rootNavigator?.canPop() ?? false) { + await rootNavigator?.maybePop(); + return; + } + await profileNavigationRegistry.maybePopProfileRoute(); + }()); } }, behavior: HitTestBehavior.translucent, @@ -974,7 +890,7 @@ class _AppShell extends StatelessWidget { darkTheme: themeProvider.darkTheme, themeMode: themeProvider.materialThemeMode, navigatorKey: rootNavigatorKey, - navigatorObservers: [routeObserver, BackKeySuppressorObserver()], + navigatorObservers: [BackKeySuppressorObserver()], home: const OrientationAwareSetup(), // Siri Remote select + gamepad A report as // LogicalKeyboardKey.{select,gameButtonA} which aren't @@ -1111,7 +1027,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { _setStatus(t.common.startingOfflineMode); await context.read().ensureInitialized(); if (!mounted) return; - unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)))); + unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen(isOfflineMode: true)))); } Future _loadSavedCredentials() async { @@ -1314,7 +1230,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { await downloadProvider.refreshMetadataFromCache(); if (!mounted) return; - unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(initialPromptHandled: shouldPrompt)))); + unawaited(Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: shouldPrompt)))); } /// Wire per-server status updates from [MultiServerManager] into the diff --git a/lib/navigation/profile_navigation_scope.dart b/lib/navigation/profile_navigation_scope.dart new file mode 100644 index 00000000..11352d14 --- /dev/null +++ b/lib/navigation/profile_navigation_scope.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +/// Tracks the currently mounted profile-scoped navigator and main +/// ScaffoldMessenger without making their GlobalKeys app-lifetime widgets. +/// +/// The keys are created inside the profile session and registered here only so +/// app-global helpers (mouse back, background snackbars) can reach the active +/// profile surface. A profile switch unregisters the old keys before the new +/// session registers fresh ones. +class ProfileNavigationRegistry { + GlobalKey? _navigatorKey; + GlobalKey? _mainScaffoldMessengerKey; + + NavigatorState? get navigator => _navigatorKey?.currentState; + ScaffoldMessengerState? get mainScaffoldMessenger => _mainScaffoldMessengerKey?.currentState; + + void attachNavigator(GlobalKey key) { + _navigatorKey = key; + } + + void detachNavigator(GlobalKey key) { + if (identical(_navigatorKey, key)) { + _navigatorKey = null; + } + } + + void attachMainScaffoldMessenger(GlobalKey key) { + _mainScaffoldMessengerKey = key; + } + + void detachMainScaffoldMessenger(GlobalKey key) { + if (identical(_mainScaffoldMessengerKey, key)) { + _mainScaffoldMessengerKey = null; + } + } + + Future maybePopProfileRoute() async { + final state = navigator; + if (state == null) return false; + return state.maybePop(); + } +} + +final profileNavigationRegistry = ProfileNavigationRegistry(); + +class ProfileNavigationScope extends InheritedWidget { + const ProfileNavigationScope({ + super.key, + required this.navigatorKey, + required this.routeObserver, + required this.mainScaffoldMessengerKey, + required super.child, + }); + + final GlobalKey navigatorKey; + final RouteObserver> routeObserver; + final GlobalKey mainScaffoldMessengerKey; + + static ProfileNavigationScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + if (scope == null) { + throw StateError('ProfileNavigationScope is required for profile routes.'); + } + return scope; + } + + static ProfileNavigationScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(ProfileNavigationScope oldWidget) { + return navigatorKey != oldWidget.navigatorKey || + routeObserver != oldWidget.routeObserver || + mainScaffoldMessengerKey != oldWidget.mainScaffoldMessengerKey; + } +} diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart new file mode 100644 index 00000000..f8e8d3b0 --- /dev/null +++ b/lib/navigation/profile_session_screen.dart @@ -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 createState() => _ProfileSessionScreenState(); +} + +class _ProfileSessionScreenState extends State { + // 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( + builder: (context, activeProfile, _) { + final activeId = activeProfile.activeId; + final initialPromptHandled = widget.initialPromptHandled || _hasBuiltSession; + return KeyedSubtree( + key: ValueKey('profile-session:$activeId'), + child: MultiProvider( + providers: [ + ChangeNotifierProxyProvider( + 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()), + lazy: true, + ), + ChangeNotifierProvider( + create: (context) => LibrariesProvider( + storageService: context.read(), + multiServer: context.read(), + ), + ), + ChangeNotifierProvider( + create: (context) { + final activeProfile = context.read(); + return DiscoverProvider( + context.read(), + context.read(), + context.read(), + 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(); + final _mainScaffoldMessengerKey = GlobalKey(); + final _routeObserver = RouteObserver>(); + + @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 _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( + settings: settings, + builder: (context) => + widget.profileShellBuilder?.call(context) ?? + MainScreen(isOfflineMode: widget.isOfflineMode, initialPromptHandled: widget.initialPromptHandled), + ); + } +} diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 002a9529..84208b22 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -20,11 +20,11 @@ import '../focus/focusable_button.dart'; import '../focus/focusable_text_field.dart'; import '../focus/key_event_utils.dart'; import '../media/media_backend.dart'; +import '../navigation/profile_session_screen.dart'; import '../utils/navigation_transitions.dart'; import '../widgets/backend_badge.dart'; import '../widgets/dialog_action_button.dart'; import 'auth/plex_pin_auth_flow.dart'; -import 'main_screen.dart'; import 'profile/profile_switch_screen.dart'; import 'settings/add_jellyfin_screen.dart'; @@ -172,7 +172,9 @@ class _AuthScreenState extends State { await context.read().initialize(); if (!mounted) return; - unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(initialPromptHandled: promptHandled)))); + unawaited( + Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: promptHandled))), + ); } catch (e) { appLogger.e('Failed to connect to servers', error: e); if (!mounted) return; @@ -196,7 +198,7 @@ class _AuthScreenState extends State { // The connection persisted and the manager registered the client; move // straight to the main screen. [MainScreen] reads the active client // from the server provider, so no client argument is needed here. - unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen()))); + unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen()))); } void _showDebugTokenDialog() { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index a2db5202..79b7d41a 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -793,7 +793,10 @@ class _DiscoverScreenState extends State } void _handleSwitchProfile(BuildContext context) { - Navigator.push(context, MaterialPageRoute(builder: (context) => const ProfileSwitchScreen())); + Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen())); } void _handleOpenSettings(BuildContext context) { diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 78b2b496..5c778532 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -677,7 +677,7 @@ class _LibrariesScreenState extends State final allLibraries = librariesProvider.libraries; if (PlatformDetector.isTV()) { - showDialog( + showScopedDialog( context: context, builder: (context) => _LibraryManagementSheet( isDialog: true, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 3bb60eba..61f7ab2f 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -23,12 +23,12 @@ import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../utils/update_dialog.dart'; import '../utils/video_player_navigation.dart'; -import '../main.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/refreshable.dart'; import '../widgets/overlay_sheet.dart'; import '../mixins/tab_visibility_aware.dart'; import '../navigation/navigation_tabs.dart'; +import '../navigation/profile_navigation_scope.dart'; import '../profiles/active_profile_binder.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; @@ -161,6 +161,7 @@ class _MainScreenState extends State OfflineModeProvider? _offlineModeProvider; MultiServerProvider? _multiServerProvider; + RouteObserver>? _profileRouteObserver; bool _lastHasLiveTv = false; /// Whether a reconnection attempt is in progress @@ -522,6 +523,7 @@ class _MainScreenState extends State _setTvosMenuPassthrough(false); await Navigator.of( context, + rootNavigator: true, ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); if (!mounted) return; _isShowingProfileSelection = false; @@ -569,9 +571,10 @@ class _MainScreenState extends State }; watchTogether.onHostExitedPlayer = () { appLogger.d('WatchTogether: Host exited player - exiting player for guest'); - // Use rootNavigator to ensure we pop the video player even if nested + // Watch Together playback lives in the profile navigator; root-level + // dialogs/profile picker must not be affected. if (!mounted) return; - final navigator = Navigator.of(context, rootNavigator: true); + final navigator = Navigator.of(context); bool isVideoPlayerOnTop = false; navigator.popUntil((route) { if (route.isCurrent) { @@ -687,7 +690,15 @@ class _MainScreenState extends State _setupCompanionRemote(); } - routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); + final scopedRouteObserver = ProfileNavigationScope.of(context).routeObserver; + if (scopedRouteObserver != _profileRouteObserver) { + _profileRouteObserver?.unsubscribe(this); + _profileRouteObserver = scopedRouteObserver; + final route = ModalRoute.of(context); + if (route is PageRoute) { + scopedRouteObserver.subscribe(this, route); + } + } } void _setupCompanionRemote() { @@ -742,7 +753,7 @@ class _MainScreenState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); - routeObserver.unsubscribe(this); + _profileRouteObserver?.unsubscribe(this); if (PlatformDetector.isDesktopOS()) { windowManager.removeListener(this); windowManager.setPreventClose(false); @@ -806,6 +817,7 @@ class _MainScreenState extends State _setTvosMenuPassthrough(false); await Navigator.of( context, + rootNavigator: true, ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); if (!mounted) return; _isShowingProfileSelection = false; @@ -1637,7 +1649,7 @@ class _MainScreenState extends State _handleMainBack(); }, child: ScaffoldMessenger( - key: mainScaffoldMessengerKey, + key: ProfileNavigationScope.of(context).mainScaffoldMessengerKey, child: Scaffold( body: _buildTickerAwareStack(), bottomNavigationBar: Column( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 19bb0265..d60becd3 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:cached_network_image_ce/cached_network_image.dart'; import 'package:flutter/material.dart'; -import '../main.dart' show routeObserver; +import '../navigation/profile_navigation_scope.dart'; import '../services/image_cache_service.dart'; import 'package:flutter/services.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -261,6 +261,7 @@ class _MediaDetailScreenState extends State bool _hasLoadedRelatedHubs = false; final _tvDetailRailKey = GlobalKey(); PageRoute? _route; + RouteObserver>? _routeObserver; late final ScrollController _scrollController; final ScrollController _extrasScrollController = ScrollController(); bool _watchStateChanged = false; @@ -662,10 +663,13 @@ class _MediaDetailScreenState extends State @override void didChangeDependencies() { super.didChangeDependencies(); + final routeObserver = ProfileNavigationScope.of(context).routeObserver; final route = ModalRoute.of(context); - if (route is! PageRoute || route == _route) return; - if (_route != null) routeObserver.unsubscribe(this); + if (route is! PageRoute) return; + if (route == _route && routeObserver == _routeObserver) return; + _routeObserver?.unsubscribe(this); _route = route; + _routeObserver = routeObserver; routeObserver.subscribe(this, route); } @@ -771,7 +775,7 @@ class _MediaDetailScreenState extends State @override void dispose() { - routeObserver.unsubscribe(this); + _routeObserver?.unsubscribe(this); _scrollController.dispose(); _scrollOffset.dispose(); _extrasScrollController.dispose(); diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index 60ef88f6..e493ce5b 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -142,6 +142,7 @@ class _MetadataEditScreenState extends State { initialDate: initial, firstDate: DateTime(1800), lastDate: DateTime(2200), + useRootNavigator: false, ); if (picked != null && mounted) { @@ -154,7 +155,7 @@ class _MetadataEditScreenState extends State { Future _editStringList(MetadataEditField field) async { final draft = _draft; if (draft == null) return; - final result = await showDialog>( + final result = await showScopedDialog>( context: context, builder: (context) => TagEditDialog(title: field.label, initialTags: metadataStringList(draft.values[field.id])), ); @@ -168,7 +169,7 @@ class _MetadataEditScreenState extends State { final draft = _draft; if (adapter == null || draft == null) return; final current = draft.value(field.id) ?? ''; - final result = await showDialog( + final result = await showScopedDialog( context: context, builder: (dialogContext) { String selected = current; @@ -229,7 +230,7 @@ class _MetadataEditScreenState extends State { final adapter = _adapter; final draft = _draft; if (adapter == null || draft == null) return; - final result = await showDialog( + final result = await showScopedDialog( context: context, builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field), ); diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index d3d75df9..080a3400 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -687,8 +687,11 @@ class _PinKey { /// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled Future showPinEntryDialog(BuildContext context, String userName, {String? errorMessage}) { + // PIN entry guards root-owned profile activation and must survive disposal of + // the active profile session while the binder switches users. return showDialog( context: context, + useRootNavigator: true, barrierDismissible: false, builder: (context) => PinEntryDialog(userName: userName, errorMessage: errorMessage), ); diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index 61e67510..af7b197a 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -368,7 +368,7 @@ class _ProfileSwitchScreenState extends State with MountedS if (_switching) return; setState(() => _switching = true); try { - final navigator = Navigator.of(context); + final navigator = Navigator.of(context, rootNavigator: true); final switched = await switchProfileFromUi(context, profile); if (!mounted || !switched) return; if (widget.requireSelection) { diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index a71c0912..aba38fe6 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -371,6 +371,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta )) { await Navigator.of( context, + rootNavigator: true, ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true))); if (!mounted) return; boundProfile = activeProvider.active; diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 62c435af..9b5a4aff 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -384,6 +384,6 @@ class AppearanceSettingsScreen extends StatelessWidget { } void _restartApp(BuildContext context) { - Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); + Navigator.of(context, rootNavigator: true).pushNamedAndRemoveUntil('/', (route) => false); } } diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index ed2e98d1..f246edfb 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -10,6 +10,7 @@ import '../../focus/focusable_text_field.dart'; import '../../i18n/strings.g.dart'; import '../../models/external_player_models.dart'; import '../../services/settings_service.dart'; +import '../../utils/dialogs.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_page.dart'; @@ -119,7 +120,7 @@ class _PlayerTile extends StatelessWidget { typedef _CustomPlayerDialogResult = ({String name, String value, CustomPlayerType type}); Future _showAddCustomPlayerDialog(BuildContext context) async { - final result = await showDialog<_CustomPlayerDialogResult>( + final result = await showScopedDialog<_CustomPlayerDialogResult>( context: context, builder: (_) => const _AddCustomPlayerDialog(), ); diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index ded15a96..f696cffd 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -4,6 +4,7 @@ import '../../i18n/strings.g.dart'; import '../../models/hotkey_model.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/shader_service.dart'; +import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -79,7 +80,7 @@ class KeyboardShortcutsScreen extends StatelessWidget { } void _editHotkey(BuildContext screenContext, String action, HotKey currentHotkey) { - showDialog( + showScopedDialog( context: screenContext, builder: (BuildContext context) { return HotKeyRecorderWidget( diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 2ea5195f..e85a49af 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -150,7 +150,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { final id = (data as Map)['id'] as String; unawaited( - showDialog( + showScopedDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t.messages.logsUploaded), diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 75e04482..f3825d0c 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -293,7 +293,10 @@ class _SettingsScreenState extends State with FocusableTab, Moun icon: Symbols.group_rounded, title: t.profiles.sectionTitle, subtitle: subtitle, - destinationBuilder: (_) => const ProfileSwitchScreen(), + onTap: () => Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())), ); }, ); @@ -534,7 +537,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); - await showDialog( + await showScopedDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(t.settings.downloads), @@ -644,7 +647,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _showRelayUrlDialog() async { - await showDialog( + await showScopedDialog( context: context, builder: (_) => _RelayUrlDialog(settingsService: _settingsService), ); diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index d4eb6591..ce61cdb0 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; +import '../../utils/dialogs.dart'; import '../../widgets/dialog_action_button.dart'; import '../../widgets/focusable_list_tile.dart'; import '../../widgets/tv_color_picker.dart'; @@ -36,7 +37,7 @@ void _showSettingsInputDialog({ _SettingsDialogActionsBuilder? leadingActionsBuilder, VoidCallback? onDispose, }) { - showDialog( + showScopedDialog( context: context, builder: (_) => _SettingsInputDialog( title: title, @@ -105,7 +106,7 @@ Future showSelectionDialog({ required T currentValue, }) { final focusFirstItem = InputModeTracker.isKeyboardMode(context); - return showDialog( + return showScopedDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(title), diff --git a/lib/screens/settings/tracker_connect_launcher.dart b/lib/screens/settings/tracker_connect_launcher.dart index 0bf3e0b5..a1587796 100644 --- a/lib/screens/settings/tracker_connect_launcher.dart +++ b/lib/screens/settings/tracker_connect_launcher.dart @@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; import '../../utils/app_logger.dart'; +import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; /// Shared "connect this tracker" launcher. @@ -33,7 +34,7 @@ Future launchTrackerConnect( final ok = await connect((payload) { if (!context.mounted) return; dialogOpen = true; - showDialog( + showScopedDialog( context: context, barrierDismissible: false, builder: (_) => buildDialog(payload, () { @@ -58,7 +59,7 @@ Future launchTrackerConnect( // the Cancel button). This is the ONLY site that dismisses the dialog — // popping here and having the dialog self-pop would pop the screen behind. if (dialogOpen) { - Navigator.of(context, rootNavigator: true).pop(); + Navigator.of(context).pop(); } if (!ok) { showAppSnackBar(context, t.trackers.connectFailed(service: serviceName)); diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart index 958b7692..69ed5a9f 100644 --- a/lib/services/media_list_playback_launcher.dart +++ b/lib/services/media_list_playback_launcher.dart @@ -10,6 +10,7 @@ import '../media/media_playlist.dart'; import '../media/play_queue.dart'; import '../providers/playback_state_provider.dart'; import '../utils/app_logger.dart'; +import '../utils/dialogs.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; import 'jellyfin_sequential_launcher.dart'; @@ -131,7 +132,7 @@ abstract class MediaListPlaybackLauncher { if (showLoading && context.mounted) { loadingVisible = true; unawaited( - showDialog( + showScopedDialog( context: context, barrierDismissible: false, builder: (dialogContext) { @@ -162,14 +163,12 @@ abstract class MediaListPlaybackLauncher { showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); } - await dismissLoading(); return result; } catch (e) { appLogger.e('Failed to $actionLabel', error: e); if (context.mounted) { showErrorSnackBar(context, t.messages.failedPlayback(action: actionLabel, error: e.toString())); } - await dismissLoading(); return PlayQueueError(e); } finally { await dismissLoading(); diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index cd7ea146..9d09aba4 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -13,6 +13,22 @@ import 'focus_utils.dart'; const _buttonPadding = EdgeInsets.symmetric(horizontal: 18, vertical: 14); const _buttonShape = StadiumBorder(); +/// Shows a dialog on the nearest navigator instead of Flutter's default root +/// navigator. Use this for profile/session-owned modal routes so they are +/// disposed when the active profile session is replaced. +Future showScopedDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, +}) { + return showDialog( + context: context, + builder: builder, + barrierDismissible: barrierDismissible, + useRootNavigator: false, + ); +} + /// Shows a confirmation dialog with consistent button sizing and autofocus. /// Returns true if user confirmed, false if cancelled. Future showConfirmDialog( @@ -23,7 +39,7 @@ Future showConfirmDialog( String? cancelText, bool isDestructive = false, }) async { - final confirmed = await showDialog( + final confirmed = await showScopedDialog( context: context, builder: (dialogContext) { final colorScheme = Theme.of(dialogContext).colorScheme; @@ -61,7 +77,7 @@ Future showConfirmDialog( /// Shows a non-dismissible loading-spinner dialog. Caller is responsible for /// closing it via `Navigator.pop(context)` when the work completes. void showLoadingDialog(BuildContext context) { - showDialog( + showScopedDialog( context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()), @@ -70,7 +86,7 @@ void showLoadingDialog(BuildContext context) { /// Shows the server-side 500 modal (bandwidth/transcoding limit rejection). Future showServerLimitDialog(BuildContext context) async { - await showDialog( + await showScopedDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( @@ -122,7 +138,7 @@ Future showTextInputDialog( String? Function(String)? validator, bool allowEmpty = false, }) { - return showDialog( + return showScopedDialog( context: context, builder: (context) => _TextInputDialog( title: title, @@ -147,7 +163,7 @@ Future showMultilineTextInputDialog( required String labelText, String? initialValue, }) { - return showDialog( + return showScopedDialog( context: context, builder: (context) => _MultilineTextInputDialog(title: title, labelText: labelText, initialValue: initialValue), ); @@ -313,7 +329,7 @@ Future showOptionPickerDialog( Future Function(T value)? onBeforeClose, }) { final focusFirstItem = InputModeTracker.isKeyboardMode(context); - return showDialog( + return showScopedDialog( context: context, builder: (context) => _OptionPickerDialog( title: title, diff --git a/lib/utils/smart_deletion_handler.dart b/lib/utils/smart_deletion_handler.dart index 6fcd39f5..5a14bb3e 100644 --- a/lib/utils/smart_deletion_handler.dart +++ b/lib/utils/smart_deletion_handler.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../i18n/strings.g.dart'; import '../providers/download_provider.dart'; import '../widgets/deletion_progress_dialog.dart'; +import 'dialogs.dart'; class SmartDeletionHandler { /// Execute deletion with smart progress dialog @@ -35,7 +36,7 @@ class SmartDeletionHandler { } static void _showProgressDialog(BuildContext context, DownloadProvider _, String globalKey) { - showDialog( + showScopedDialog( context: context, barrierDismissible: false, builder: (dialogContext) => Consumer( diff --git a/lib/utils/snackbar_helper.dart b/lib/utils/snackbar_helper.dart index 8f877953..1ef5ad74 100644 --- a/lib/utils/snackbar_helper.dart +++ b/lib/utils/snackbar_helper.dart @@ -1,15 +1,12 @@ import 'package:flutter/material.dart'; +import '../navigation/profile_navigation_scope.dart'; import 'layout_constants.dart'; import 'platform_detector.dart'; /// Global key for the root ScaffoldMessenger, allowing snackbars to survive navigation. final rootScaffoldMessengerKey = GlobalKey(); -/// Nested messenger inside MainScreen — its Scaffold owns the bottom NavigationBar -/// so floating snackbars auto-offset above the navbar. -final mainScaffoldMessengerKey = GlobalKey(); - /// Types of snackbars available in the app enum SnackBarType { info, success, error } @@ -119,7 +116,7 @@ void showGlobalErrorSnackBar(String message) { /// (so it floats above the mobile NavigationBar), falling back to the root /// messenger when the main screen is not mounted. void showMainSnackBar(String message, {Duration duration = AppDurations.snackBarDefault}) { - final messenger = mainScaffoldMessengerKey.currentState ?? rootScaffoldMessengerKey.currentState; + final messenger = profileNavigationRegistry.mainScaffoldMessenger ?? rootScaffoldMessengerKey.currentState; if (messenger == null) return; messenger ..removeCurrentSnackBar() diff --git a/lib/utils/update_dialog.dart b/lib/utils/update_dialog.dart index a26b1b55..a146be0b 100644 --- a/lib/utils/update_dialog.dart +++ b/lib/utils/update_dialog.dart @@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../i18n/strings.g.dart'; import '../services/update_service.dart'; import '../widgets/dialog_action_button.dart'; +import 'dialogs.dart'; Future showUpdateAvailableDialog( BuildContext context, @@ -12,7 +13,7 @@ Future showUpdateAvailableDialog( required String dismissLabel, bool showSkipVersion = false, }) { - return showDialog( + return showScopedDialog( context: context, builder: (dialogContext) { final latestVersion = updateInfo['latestVersion'] as String; diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index a565cb15..02448589 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -241,7 +241,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta } Future _showControlModeDialog() { - return showDialog( + return showScopedDialog( context: context, builder: (context) => AlertDialog( title: Text(t.watchTogether.controlMode), @@ -308,7 +308,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta } Future _renameRoom(RecentRoom room) async { - final name = await showDialog( + final name = await showScopedDialog( context: context, builder: (_) => _RenameRoomDialog(room: room), ); diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index 44d03642..bb845b57 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -7,6 +7,7 @@ import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; +import '../../utils/dialogs.dart'; class JoinSessionDialog extends StatefulWidget { const JoinSessionDialog({super.key}); @@ -153,5 +154,5 @@ class UpperCaseTextFormatter extends TextInputFormatter { } Future showJoinSessionDialog(BuildContext context) { - return showDialog(context: context, builder: (context) => const JoinSessionDialog()); + return showScopedDialog(context: context, builder: (context) => const JoinSessionDialog()); } diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index bf60bdcf..7699e8d6 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -6,6 +6,7 @@ import '../../mixins/mounted_set_state_mixin.dart'; import '../../providers/companion_remote_provider.dart'; import '../../services/companion_remote/companion_remote_host_controller.dart'; import '../../services/settings_service.dart'; +import '../../utils/dialogs.dart'; import '../../focus/focusable_button.dart'; import '../../focus/key_event_utils.dart'; @@ -16,7 +17,7 @@ class RemoteSessionDialog extends StatefulWidget { State createState() => _RemoteSessionDialogState(); static Future show(BuildContext context) { - return showDialog( + return showScopedDialog( context: context, barrierDismissible: false, builder: (context) => const RemoteSessionDialog(), diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index c1422181..8b85567f 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -87,7 +87,7 @@ class DeviceCodeDialog extends StatelessWidget { DialogActionButton( onPressed: () { onCancel(); - Navigator.of(context, rootNavigator: true).pop(); + Navigator.of(context).pop(); }, label: t.common.cancel, ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 75a3cc48..a2681c53 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -947,7 +947,7 @@ class MediaContextMenuState extends State { try { final item = _mediaItem!; - final result = await showDialog( + final result = await showScopedDialog( context: context, builder: (context) => _PlaylistSelectionDialog(client: client), ); @@ -1051,7 +1051,7 @@ class MediaContextMenuState extends State { final resolvedLibraryId = libraryId; if (!context.mounted) return; - final result = await showDialog( + final result = await showScopedDialog( context: context, builder: (context) => _CollectionSelectionDialog(client: client, libraryId: resolvedLibraryId), ); diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index edad90eb..dd4e2749 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -96,7 +96,7 @@ class OAuthProxyDialog extends StatelessWidget { DialogActionButton( onPressed: () { onCancel(); - Navigator.of(context, rootNavigator: true).pop(); + Navigator.of(context).pop(); }, label: t.common.cancel, ), diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index 8348919f..fe3b4953 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -84,9 +84,10 @@ TvVirtualKeyboardHandle? showTvVirtualKeyboard({ if (!PlatformDetector.isTV()) return null; // A hand-built DialogRoute instead of showDialog so the caller gets a - // handle it can close when the owning field unmounts. Mirrors showDialog's - // root-navigator, captured-themes and closed-loop traversal defaults. - final navigator = Navigator.of(context, rootNavigator: true); + // handle it can close when the owning field unmounts. Use the nearest + // navigator so profile-owned keyboards are disposed with their profile + // session. + final navigator = Navigator.of(context); final route = DialogRoute( context: context, barrierDismissible: true, diff --git a/test/navigation/profile_navigation_scope_test.dart b/test/navigation/profile_navigation_scope_test.dart new file mode 100644 index 00000000..4eeb2f11 --- /dev/null +++ b/test/navigation/profile_navigation_scope_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/navigation/profile_navigation_scope.dart'; + +void main() { + testWidgets('ProfileNavigationRegistry pops and detaches the active profile navigator', (tester) async { + final registry = ProfileNavigationRegistry(); + final firstKey = GlobalKey(); + final secondKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Navigator( + key: firstKey, + onGenerateRoute: (_) => MaterialPageRoute(builder: (_) => const Text('first')), + ), + ), + ); + + registry.attachNavigator(firstKey); + firstKey.currentState!.push(MaterialPageRoute(builder: (_) => const Text('second'))); + await tester.pumpAndSettle(); + expect(find.text('second'), findsOneWidget); + + expect(await registry.maybePopProfileRoute(), isTrue); + await tester.pumpAndSettle(); + expect(find.text('second'), findsNothing); + + registry.detachNavigator(secondKey); + expect(registry.navigator, same(firstKey.currentState)); + + registry.detachNavigator(firstKey); + expect(registry.navigator, isNull); + }); +} diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart new file mode 100644 index 00000000..4ba98f3f --- /dev/null +++ b/test/navigation/profile_session_screen_test.dart @@ -0,0 +1,185 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/navigation/profile_navigation_scope.dart'; +import 'package:plezy/navigation/profile_session_screen.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/discover_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/storage_service.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + }); + + testWidgets('profile switch disposes the profile navigator, routes, and providers', (tester) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final profileRegistry = ProfileRegistry(db); + final connectionRegistry = ConnectionRegistry(db); + final profileConnectionRegistry = ProfileConnectionRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = _FakePlexHomeService( + connections: connectionRegistry, + profileConnections: profileConnectionRegistry, + storage: storage, + ); + final activeProfile = ActiveProfileProvider( + registry: profileRegistry, + plexHome: plexHome, + connections: connectionRegistry, + storage: storage, + ); + final serverManager = MultiServerManager(); + final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final discoverProviders = []; + final disposedActiveIds = []; + + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await activeProfile.resetForTesting(); + activeProfile.dispose(); + multiServer.dispose(); + serverManager.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final owner = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + final kids = Profile.local(id: 'local-kids', displayName: 'Kids', createdAt: DateTime(2026, 1, 2)); + await profileRegistry.upsert(owner); + await profileRegistry.upsert(kids); + await storage.setActiveProfileId(owner.id); + await activeProfile.initialize(); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: storage), + ChangeNotifierProvider.value(value: activeProfile), + ChangeNotifierProvider.value(value: multiServer), + ], + child: MaterialApp( + home: ProfileSessionScreen.forTesting( + initialPromptHandled: true, + profileShellBuilder: (context) => + _ProfileProbeShell(discoverProviders: discoverProviders, disposedActiveIds: disposedActiveIds), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('active:local-owner'), findsOneWidget); + expect(discoverProviders, hasLength(1)); + final ownerNavigator = profileNavigationRegistry.navigator; + final ownerDiscover = discoverProviders.single; + + await tester.tap(find.byKey(const ValueKey('push-profile-route'))); + await tester.pumpAndSettle(); + expect(find.text('old profile route'), findsOneWidget); + + expect(await activeProfile.activate(kids), isTrue); + await tester.pumpAndSettle(); + + expect(find.text('old profile route'), findsNothing); + expect(find.text('active:local-kids'), findsOneWidget); + expect(disposedActiveIds, contains('local-owner')); + expect(discoverProviders, hasLength(2)); + expect(discoverProviders.last, isNot(same(ownerDiscover))); + expect(profileNavigationRegistry.navigator, isNot(same(ownerNavigator))); + }); +} + +class _ProfileProbeShell extends StatefulWidget { + const _ProfileProbeShell({required this.discoverProviders, required this.disposedActiveIds}); + + final List discoverProviders; + final List disposedActiveIds; + + @override + State<_ProfileProbeShell> createState() => _ProfileProbeShellState(); +} + +class _ProfileProbeShellState extends State<_ProfileProbeShell> { + DiscoverProvider? _discoverProvider; + String _activeId = 'none'; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _discoverProvider = context.read(); + _activeId = context.read().activeId ?? 'none'; + if (widget.discoverProviders.isEmpty || !identical(widget.discoverProviders.last, _discoverProvider)) { + widget.discoverProviders.add(_discoverProvider!); + } + } + + @override + void dispose() { + widget.disposedActiveIds.add(_activeId); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final activeId = context.watch().activeId; + return Scaffold( + body: Column( + children: [ + Text('active:$activeId'), + ElevatedButton( + key: const ValueKey('push-profile-route'), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const Text('old profile route'))); + }, + child: const Text('push profile route'), + ), + ], + ), + ); + } +} + +class _FakePlexHomeService extends PlexHomeService { + _FakePlexHomeService({ + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, + required StorageService storage, + }) : super( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + + @override + Map> get current => const {}; + + @override + Stream>> get stream => Stream.value(const {}); + + @override + Future start() async {} + + @override + Future reloadFromStorage() async {} + + @override + Future dispose() async {} +} diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 85875066..c2f6637a 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -37,6 +37,7 @@ import 'package:plezy/widgets/tv_browse_rail.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_navigation.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -72,7 +73,7 @@ void main() { TranslationProvider( child: MaterialApp( theme: monoTheme(dark: true), - home: MediaDetailScreen(metadata: movie), + home: withProfileNavigationScope(child: MediaDetailScreen(metadata: movie)), ), ), ); @@ -101,7 +102,7 @@ void main() { TranslationProvider( child: MaterialApp( theme: monoTheme(dark: true), - home: MediaDetailScreen(metadata: movie), + home: withProfileNavigationScope(child: MediaDetailScreen(metadata: movie)), ), ), ); @@ -138,7 +139,7 @@ void main() { TranslationProvider( child: MaterialApp( theme: monoTheme(dark: true), - home: MediaDetailScreen(metadata: movie), + home: withProfileNavigationScope(child: MediaDetailScreen(metadata: movie)), ), ), ); @@ -172,7 +173,7 @@ void main() { TranslationProvider( child: MaterialApp( theme: monoTheme(dark: true), - home: MediaDetailScreen(metadata: movie), + home: withProfileNavigationScope(child: MediaDetailScreen(metadata: movie)), ), ), ); @@ -261,7 +262,9 @@ void main() { value: provider, child: MaterialApp( theme: monoTheme(dark: true), - home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + home: withProfileNavigationScope( + child: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + ), ), ), ), @@ -298,7 +301,7 @@ void main() { TranslationProvider( child: MaterialApp( theme: theme, - home: MediaDetailScreen(metadata: movie), + home: withProfileNavigationScope(child: MediaDetailScreen(metadata: movie)), ), ), ); @@ -385,7 +388,9 @@ void main() { value: provider, child: MaterialApp( theme: monoTheme(dark: true), - home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + home: withProfileNavigationScope( + child: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + ), ), ), ), @@ -480,7 +485,9 @@ void main() { value: provider, child: MaterialApp( theme: monoTheme(dark: true), - home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + home: withProfileNavigationScope( + child: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + ), ), ), ), @@ -569,7 +576,9 @@ void main() { value: provider, child: MaterialApp( theme: monoTheme(dark: true), - home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + home: withProfileNavigationScope( + child: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + ), ), ), ), @@ -652,7 +661,11 @@ void main() { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); JellyfinApiCache.initialize(db); - final downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance, clientResolver: (serverId, {clientScopeId}) => null); + final downloadManager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (serverId, {clientScopeId}) => null, + ); downloadManager.recoveryFuture = Future.value(); final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await downloadProvider.ensureInitialized(); @@ -679,11 +692,13 @@ void main() { ], child: MaterialApp( theme: monoTheme(dark: true), - home: MediaDetailScreen( - metadata: show, - initialSeasonId: initialSeasonId, - initialSeasonIndex: initialSeasonIndex, - initialEpisodeId: initialEpisodeId, + home: withProfileNavigationScope( + child: MediaDetailScreen( + metadata: show, + initialSeasonId: initialSeasonId, + initialSeasonIndex: initialSeasonIndex, + initialEpisodeId: initialEpisodeId, + ), ), ), ), diff --git a/test/test_helpers/profile_navigation.dart b/test/test_helpers/profile_navigation.dart new file mode 100644 index 00000000..003f122d --- /dev/null +++ b/test/test_helpers/profile_navigation.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/navigation/profile_navigation_scope.dart'; + +Widget withProfileNavigationScope({required Widget child}) { + return ProfileNavigationScope( + navigatorKey: GlobalKey(), + routeObserver: RouteObserver>(), + mainScaffoldMessengerKey: GlobalKey(), + child: child, + ); +}