fix(profiles): reuse switch flow for menu

This commit is contained in:
edde746
2026-05-10 00:36:08 +02:00
parent ec93062600
commit dd0fd1c828
4 changed files with 81 additions and 51 deletions
+23
View File
@@ -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<bool> 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<bool> switchProfileFromUi(BuildContext context, Profile profile) async {
final activeProvider = context.read<ActiveProfileProvider>();
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
+20 -2
View File
@@ -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<DiscoverScreen>
List<MediaHub> _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<DiscoverScreen>
final profiles = activeProvider.profiles;
return FocusableAction(
onPressed: () => _showUserMenu(context),
onPressed: _switchingProfile ? null : () => _showUserMenu(context),
child: PopupMenuButton<String>(
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<DiscoverScreen>
}
Future<void> _handleUserMenuAction(BuildContext context, String value) async {
if (_switchingProfile) return;
if (value == 'logout') {
unawaited(_handleLogout());
return;
@@ -968,12 +972,25 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final active = context.read<ActiveProfileProvider>();
final target = active.profiles.where((p) => p.id == id).firstOrNull;
if (target == null) return;
await activateProfileWithPin(context, target);
await _switchProfileFromMenu(target);
}
}
Future<void> _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<DiscoverScreen>
// 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(),
],
),
);
+4 -49
View File
@@ -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<ProfileSwitchScreen> 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<ProfileSwitchScreen> with MountedS
setState(() => _switching = true);
try {
final navigator = Navigator.of(context);
final activeProvider = context.read<ActiveProfileProvider>();
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);
}
@@ -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),
],
),
),
),
),
],
),
);
}
}