From dd0fd1c828a7247a9cba17a6aa6d47ec8f19b286 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 10 May 2026 00:36:08 +0200 Subject: [PATCH] fix(profiles): reuse switch flow for menu --- lib/profiles/profile_activation.dart | 23 ++++++++ lib/screens/discover_screen.dart | 22 +++++++- .../profile/profile_switch_screen.dart | 53 ++----------------- lib/widgets/profile_switching_overlay.dart | 34 ++++++++++++ 4 files changed, 81 insertions(+), 51 deletions(-) create mode 100644 lib/widgets/profile_switching_overlay.dart diff --git a/lib/profiles/profile_activation.dart b/lib/profiles/profile_activation.dart index dff129bf..f340eb9b 100644 --- a/lib/profiles/profile_activation.dart +++ b/lib/profiles/profile_activation.dart @@ -3,8 +3,10 @@ import 'package:provider/provider.dart'; import '../connection/connection.dart'; import '../connection/connection_registry.dart'; +import '../i18n/strings.g.dart'; import '../screens/profile/pin_entry_dialog.dart'; import '../services/plex_auth_service.dart'; +import '../utils/snackbar_helper.dart'; import 'active_profile_binder.dart'; import 'active_profile_provider.dart'; import 'plex_home_switch.dart'; @@ -57,6 +59,27 @@ Future activateProfileWithPin(BuildContext context, Profile profile) async } } +/// Activate [profile] from a UI surface, then wait until the active profile's +/// server/token binding has settled. Shows the standard switch failure message +/// for both activation and binding failures. +Future switchProfileFromUi(BuildContext context, Profile profile) async { + final activeProvider = context.read(); + final ok = await activateProfileWithPin(context, profile); + if (!context.mounted) return false; + if (!ok) { + showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); + return false; + } + + final bound = await activeProvider.awaitBindingSettle(); + if (!context.mounted) return false; + if (!bound) { + showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); + return false; + } + return true; +} + /// Validate [profile]'s PIN with Plex via `/home/users/{uuid}/switch`. On /// success, persist the minted user-token and mark the profile as /// pre-verified so [ActiveProfileBinder] reuses the cached token instead diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 8d9cd5ab..4e74dea2 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -26,6 +26,7 @@ import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../widgets/hub_section.dart'; import '../widgets/loading_indicator_box.dart'; +import '../widgets/profile_switching_overlay.dart'; import 'profile/profile_switch_screen.dart'; import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; @@ -116,6 +117,7 @@ class _DiscoverScreenState extends State List _hubs = []; bool _isLoading = true; bool _areHubsLoading = true; + bool _switchingProfile = false; String? _errorMessage; final PageController _heroController = PageController(); final ScrollController _scrollController = ScrollController(); @@ -898,8 +900,9 @@ class _DiscoverScreenState extends State final profiles = activeProvider.profiles; return FocusableAction( - onPressed: () => _showUserMenu(context), + onPressed: _switchingProfile ? null : () => _showUserMenu(context), child: PopupMenuButton( + enabled: !_switchingProfile, icon: active != null ? ProfileAvatar(profile: active, size: 32) : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), @@ -955,6 +958,7 @@ class _DiscoverScreenState extends State } Future _handleUserMenuAction(BuildContext context, String value) async { + if (_switchingProfile) return; if (value == 'logout') { unawaited(_handleLogout()); return; @@ -968,12 +972,25 @@ class _DiscoverScreenState extends State final active = context.read(); final target = active.profiles.where((p) => p.id == id).firstOrNull; if (target == null) return; - await activateProfileWithPin(context, target); + await _switchProfileFromMenu(target); + } + } + + Future _switchProfileFromMenu(Profile profile) async { + if (_switchingProfile) return; + setState(() => _switchingProfile = true); + try { + await switchProfileFromUi(context, profile); + } finally { + if (mounted) { + setState(() => _switchingProfile = false); + } } } /// Show user menu programmatically (for D-pad select) void _showUserMenu(BuildContext context) { + if (_switchingProfile) return; final actionBar = _actionBarKey.currentState; if (actionBar == null) return; final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1); @@ -1295,6 +1312,7 @@ class _DiscoverScreenState extends State // initial/tab-switch focus lands on content (hero/hubs), not the toolbar. // Toolbar buttons are still reachable via explicit UP from hero section. Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())), + if (_switchingProfile) const ProfileSwitchingOverlay(), ], ), ); diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index fe50c341..7a5f6382 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -28,6 +28,7 @@ import '../../widgets/app_icon.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/profile_switching_overlay.dart'; import '../libraries/state_messages.dart'; import '../auth_screen.dart'; import 'add_local_profile_screen.dart'; @@ -145,33 +146,7 @@ class _ProfileSwitchScreenState extends State with MountedS ), ], ), - // Modal busy overlay so users see the switch is in flight. - // Without this the screen visually freezes for a few seconds - // while the binder fetches user tokens and rebuilds servers. - // `Positioned.fill` is required: a non-positioned `ColoredBox` - // sizes itself to its child (just the Card+Center), leaving - // the rest of the screen un-dimmed and tappable. - if (_switching) - Positioned.fill( - child: ColoredBox( - color: Colors.black54, - child: Center( - child: Card( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 56, height: 56, child: CircularProgressIndicator()), - const SizedBox(height: 16), - Text(t.profiles.switchingProfile), - ], - ), - ), - ), - ), - ), - ), + if (_switching) const ProfileSwitchingOverlay(), ], ); }, @@ -384,28 +359,8 @@ class _ProfileSwitchScreenState extends State with MountedS setState(() => _switching = true); try { final navigator = Navigator.of(context); - final activeProvider = context.read(); - final ok = await activateProfileWithPin(context, profile); - if (!mounted) return; - if (!ok) { - if (context.mounted) { - showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); - } - return; - } - // Stay on the picker while the binder mints the per-user token, - // fetches servers, and pushes them into MultiServerManager. The - // PIN dialog (if any) overlays the picker via the root navigator, - // so popping early would briefly expose the previous profile's - // empty-state screen behind the dialog. - final bound = await activeProvider.awaitBindingSettle(); - if (!mounted) return; - if (!bound) { - if (context.mounted) { - showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); - } - return; - } + final switched = await switchProfileFromUi(context, profile); + if (!mounted || !switched) return; if (widget.requireSelection) { setState(() => _allowPop = true); } diff --git a/lib/widgets/profile_switching_overlay.dart b/lib/widgets/profile_switching_overlay.dart new file mode 100644 index 00000000..11b7d892 --- /dev/null +++ b/lib/widgets/profile_switching_overlay.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; + +/// Modal Stack child shown while a profile activation is rebinding servers. +class ProfileSwitchingOverlay extends StatelessWidget { + const ProfileSwitchingOverlay({super.key}); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: Stack( + children: [ + const ModalBarrier(color: Colors.black54, dismissible: false), + Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 56, height: 56, child: CircularProgressIndicator()), + const SizedBox(height: 16), + Text(t.profiles.switchingProfile), + ], + ), + ), + ), + ), + ], + ), + ); + } +}