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
+23 -107
View File
@@ -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<PageRoute> routeObserver = RouteObserver<PageRoute>();
final rootNavigatorKey = GlobalKey<NavigatorState>();
@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<String?> _rootPinPrompt(Profile profile, {String? errorMessage}) {
final ctx = rootNavigatorKey.currentContext;
if (ctx == null) return Future.value(null);
@@ -857,98 +846,18 @@ class _MainAppState extends State<MainApp> 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<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) => 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: 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<SetupScreen> with MountedSetStateMixin {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen(isOfflineMode: true))));
}
Future<void> _loadSavedCredentials() async {
@@ -1314,7 +1230,7 @@ class _SetupScreenState extends State<SetupScreen> 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
@@ -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<NavigatorState>? _navigatorKey;
GlobalKey<ScaffoldMessengerState>? _mainScaffoldMessengerKey;
NavigatorState? get navigator => _navigatorKey?.currentState;
ScaffoldMessengerState? get mainScaffoldMessenger => _mainScaffoldMessengerKey?.currentState;
void attachNavigator(GlobalKey<NavigatorState> key) {
_navigatorKey = key;
}
void detachNavigator(GlobalKey<NavigatorState> key) {
if (identical(_navigatorKey, key)) {
_navigatorKey = null;
}
}
void attachMainScaffoldMessenger(GlobalKey<ScaffoldMessengerState> key) {
_mainScaffoldMessengerKey = key;
}
void detachMainScaffoldMessenger(GlobalKey<ScaffoldMessengerState> key) {
if (identical(_mainScaffoldMessengerKey, key)) {
_mainScaffoldMessengerKey = null;
}
}
Future<bool> 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<NavigatorState> navigatorKey;
final RouteObserver<PageRoute<dynamic>> routeObserver;
final GlobalKey<ScaffoldMessengerState> mainScaffoldMessengerKey;
static ProfileNavigationScope of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<ProfileNavigationScope>();
if (scope == null) {
throw StateError('ProfileNavigationScope is required for profile routes.');
}
return scope;
}
static ProfileNavigationScope? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ProfileNavigationScope>();
}
@override
bool updateShouldNotify(ProfileNavigationScope oldWidget) {
return navigatorKey != oldWidget.navigatorKey ||
routeObserver != oldWidget.routeObserver ||
mainScaffoldMessengerKey != oldWidget.mainScaffoldMessengerKey;
}
}
+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),
);
}
}
+5 -3
View File
@@ -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<AuthScreen> {
await context.read<UserProfileProvider>().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<AuthScreen> {
// 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() {
+4 -1
View File
@@ -793,7 +793,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
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) {
+1 -1
View File
@@ -677,7 +677,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final allLibraries = librariesProvider.libraries;
if (PlatformDetector.isTV()) {
showDialog(
showScopedDialog<void>(
context: context,
builder: (context) => _LibraryManagementSheet(
isDialog: true,
+18 -6
View File
@@ -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<MainScreen>
OfflineModeProvider? _offlineModeProvider;
MultiServerProvider? _multiServerProvider;
RouteObserver<PageRoute<dynamic>>? _profileRouteObserver;
bool _lastHasLiveTv = false;
/// Whether a reconnection attempt is in progress
@@ -522,6 +523,7 @@ class _MainScreenState extends State<MainScreen>
_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<MainScreen>
};
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<MainScreen>
_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<dynamic>) {
scopedRouteObserver.subscribe(this, route);
}
}
}
void _setupCompanionRemote() {
@@ -742,7 +753,7 @@ class _MainScreenState extends State<MainScreen>
@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<MainScreen>
_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<MainScreen>
_handleMainBack();
},
child: ScaffoldMessenger(
key: mainScaffoldMessengerKey,
key: ProfileNavigationScope.of(context).mainScaffoldMessengerKey,
child: Scaffold(
body: _buildTickerAwareStack(),
bottomNavigationBar: Column(
+8 -4
View File
@@ -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<MediaDetailScreen>
bool _hasLoadedRelatedHubs = false;
final _tvDetailRailKey = GlobalKey<TvBrowseRailState>();
PageRoute<dynamic>? _route;
RouteObserver<PageRoute<dynamic>>? _routeObserver;
late final ScrollController _scrollController;
final ScrollController _extrasScrollController = ScrollController();
bool _watchStateChanged = false;
@@ -662,10 +663,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
void didChangeDependencies() {
super.didChangeDependencies();
final routeObserver = ProfileNavigationScope.of(context).routeObserver;
final route = ModalRoute.of(context);
if (route is! PageRoute<dynamic> || route == _route) return;
if (_route != null) routeObserver.unsubscribe(this);
if (route is! PageRoute<dynamic>) 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<MediaDetailScreen>
@override
void dispose() {
routeObserver.unsubscribe(this);
_routeObserver?.unsubscribe(this);
_scrollController.dispose();
_scrollOffset.dispose();
_extrasScrollController.dispose();
+4 -3
View File
@@ -142,6 +142,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
initialDate: initial,
firstDate: DateTime(1800),
lastDate: DateTime(2200),
useRootNavigator: false,
);
if (picked != null && mounted) {
@@ -154,7 +155,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
Future<void> _editStringList(MetadataEditField field) async {
final draft = _draft;
if (draft == null) return;
final result = await showDialog<List<String>>(
final result = await showScopedDialog<List<String>>(
context: context,
builder: (context) => TagEditDialog(title: field.label, initialTags: metadataStringList(draft.values[field.id])),
);
@@ -168,7 +169,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
final draft = _draft;
if (adapter == null || draft == null) return;
final current = draft.value<String>(field.id) ?? '';
final result = await showDialog<String>(
final result = await showScopedDialog<String>(
context: context,
builder: (dialogContext) {
String selected = current;
@@ -229,7 +230,7 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
final adapter = _adapter;
final draft = _draft;
if (adapter == null || draft == null) return;
final result = await showDialog<bool>(
final result = await showScopedDialog<bool>(
context: context,
builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field),
);
@@ -687,8 +687,11 @@ class _PinKey {
/// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled
Future<String?> 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<String>(
context: context,
useRootNavigator: true,
barrierDismissible: false,
builder: (context) => PinEntryDialog(userName: userName, errorMessage: errorMessage),
);
@@ -368,7 +368,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> 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) {
@@ -371,6 +371,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
)) {
await Navigator.of(
context,
rootNavigator: true,
).push<bool>(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
if (!mounted) return;
boundProfile = activeProvider.active;
@@ -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);
}
}
@@ -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<void> _showAddCustomPlayerDialog(BuildContext context) async {
final result = await showDialog<_CustomPlayerDialogResult>(
final result = await showScopedDialog<_CustomPlayerDialogResult>(
context: context,
builder: (_) => const _AddCustomPlayerDialog(),
);
@@ -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<void>(
context: screenContext,
builder: (BuildContext context) {
return HotKeyRecorderWidget(
+1 -1
View File
@@ -150,7 +150,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
final id = (data as Map<String, dynamic>)['id'] as String;
unawaited(
showDialog(
showScopedDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.messages.logsUploaded),
+6 -3
View File
@@ -293,7 +293,10 @@ class _SettingsScreenState extends State<SettingsScreen> 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<SettingsScreen> with FocusableTab, Moun
final storageService = DownloadStorageService.instance;
final isCustom = storageService.isUsingCustomPath();
await showDialog<void>(
await showScopedDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.settings.downloads),
@@ -644,7 +647,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
}
Future<void> _showRelayUrlDialog() async {
await showDialog<void>(
await showScopedDialog<void>(
context: context,
builder: (_) => _RelayUrlDialog(settingsService: _settingsService),
);
+3 -2
View File
@@ -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<void>(
showScopedDialog<void>(
context: context,
builder: (_) => _SettingsInputDialog(
title: title,
@@ -105,7 +106,7 @@ Future<T?> showSelectionDialog<T>({
required T currentValue,
}) {
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
return showDialog<T>(
return showScopedDialog<T>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
@@ -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<void> launchTrackerConnect<T>(
final ok = await connect((payload) {
if (!context.mounted) return;
dialogOpen = true;
showDialog<void>(
showScopedDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => buildDialog(payload, () {
@@ -58,7 +59,7 @@ Future<void> launchTrackerConnect<T>(
// 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));
@@ -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<void>(
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();
+22 -6
View File
@@ -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<T?> showScopedDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
}) {
return showDialog<T>(
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<bool> showConfirmDialog(
@@ -23,7 +39,7 @@ Future<bool> showConfirmDialog(
String? cancelText,
bool isDestructive = false,
}) async {
final confirmed = await showDialog<bool>(
final confirmed = await showScopedDialog<bool>(
context: context,
builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme;
@@ -61,7 +77,7 @@ Future<bool> 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<void>(
showScopedDialog<void>(
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<void> showServerLimitDialog(BuildContext context) async {
await showDialog<void>(
await showScopedDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
@@ -122,7 +138,7 @@ Future<String?> showTextInputDialog(
String? Function(String)? validator,
bool allowEmpty = false,
}) {
return showDialog<String>(
return showScopedDialog<String>(
context: context,
builder: (context) => _TextInputDialog(
title: title,
@@ -147,7 +163,7 @@ Future<String?> showMultilineTextInputDialog(
required String labelText,
String? initialValue,
}) {
return showDialog<String>(
return showScopedDialog<String>(
context: context,
builder: (context) => _MultilineTextInputDialog(title: title, labelText: labelText, initialValue: initialValue),
);
@@ -313,7 +329,7 @@ Future<T?> showOptionPickerDialog<T>(
Future<T?> Function(T value)? onBeforeClose,
}) {
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
return showDialog<T>(
return showScopedDialog<T>(
context: context,
builder: (context) => _OptionPickerDialog<T>(
title: title,
+2 -1
View File
@@ -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<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => Consumer<DownloadProvider>(
+2 -5
View File
@@ -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<ScaffoldMessengerState>();
/// Nested messenger inside MainScreen — its Scaffold owns the bottom NavigationBar
/// so floating snackbars auto-offset above the navbar.
final mainScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
/// 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()
+2 -1
View File
@@ -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<void> showUpdateAvailableDialog(
BuildContext context,
@@ -12,7 +13,7 @@ Future<void> showUpdateAvailableDialog(
required String dismissLabel,
bool showSkipVersion = false,
}) {
return showDialog<void>(
return showScopedDialog<void>(
context: context,
builder: (dialogContext) {
final latestVersion = updateInfo['latestVersion'] as String;
@@ -241,7 +241,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
}
Future<ControlMode?> _showControlModeDialog() {
return showDialog<ControlMode>(
return showScopedDialog<ControlMode>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.watchTogether.controlMode),
@@ -308,7 +308,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
}
Future<void> _renameRoom(RecentRoom room) async {
final name = await showDialog<String>(
final name = await showScopedDialog<String>(
context: context,
builder: (_) => _RenameRoomDialog(room: room),
);
@@ -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<String?> showJoinSessionDialog(BuildContext context) {
return showDialog<String>(context: context, builder: (context) => const JoinSessionDialog());
return showScopedDialog<String>(context: context, builder: (context) => const JoinSessionDialog());
}
@@ -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<RemoteSessionDialog> createState() => _RemoteSessionDialogState();
static Future<void> show(BuildContext context) {
return showDialog<void>(
return showScopedDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => const RemoteSessionDialog(),
+1 -1
View File
@@ -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,
),
+2 -2
View File
@@ -947,7 +947,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
try {
final item = _mediaItem!;
final result = await showDialog<String>(
final result = await showScopedDialog<String>(
context: context,
builder: (context) => _PlaylistSelectionDialog(client: client),
);
@@ -1051,7 +1051,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final resolvedLibraryId = libraryId;
if (!context.mounted) return;
final result = await showDialog<String>(
final result = await showScopedDialog<String>(
context: context,
builder: (context) => _CollectionSelectionDialog(client: client, libraryId: resolvedLibraryId),
);
+1 -1
View File
@@ -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,
),
+4 -3
View File
@@ -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<void>(
context: context,
barrierDismissible: true,
@@ -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<NavigatorState>();
final secondKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
home: Navigator(
key: firstKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(builder: (_) => const Text('first')),
),
),
);
registry.attachNavigator(firstKey);
firstKey.currentState!.push(MaterialPageRoute<void>(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);
});
}
@@ -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 = <DiscoverProvider>[];
final disposedActiveIds = <String>[];
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<StorageService>.value(value: storage),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
ChangeNotifierProvider<MultiServerProvider>.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<DiscoverProvider> discoverProviders;
final List<String> disposedActiveIds;
@override
State<_ProfileProbeShell> createState() => _ProfileProbeShellState();
}
class _ProfileProbeShellState extends State<_ProfileProbeShell> {
DiscoverProvider? _discoverProvider;
String _activeId = 'none';
@override
void didChangeDependencies() {
super.didChangeDependencies();
_discoverProvider = context.read<DiscoverProvider>();
_activeId = context.read<ActiveProfileProvider>().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<ActiveProfileProvider>().activeId;
return Scaffold(
body: Column(
children: [
Text('active:$activeId'),
ElevatedButton(
key: const ValueKey('push-profile-route'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute<void>(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<String, List<PlexHomeUser>> get current => const {};
@override
Stream<Map<String, List<PlexHomeUser>>> get stream => Stream.value(const {});
@override
Future<void> start() async {}
@override
Future<void> reloadFromStorage() async {}
@override
Future<void> dispose() async {}
}
+30 -15
View File
@@ -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<void>.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,
),
),
),
),
+11
View File
@@ -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<NavigatorState>(),
routeObserver: RouteObserver<PageRoute<dynamic>>(),
mainScaffoldMessengerKey: GlobalKey<ScaffoldMessengerState>(),
child: child,
);
}