+23
-107
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user