import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../connection/connection.dart'; import '../../connection/connection_registry.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/plex/plex_home_user.dart'; import '../../profiles/active_profile_binder.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_avatar.dart'; import '../../profiles/profile_connection.dart'; import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../profiles/profiles_view.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/app_menu.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/settings_section.dart'; import '../../utils/dialogs.dart'; import '../settings/add_connection_screen.dart'; import '../settings/edit_jellyfin_connection_screen.dart'; import 'pin_entry_dialog.dart'; import 'pin_status_row.dart'; import 'profile_teardown.dart'; import 'profile_name_field.dart'; /// Manage one [Profile] — rename, change PIN, list/add/remove /// connections, set the default connection. /// /// Plex Home profiles can't be renamed (Plex owns the display name); their /// PIN lives on Plex too — both fields are read-only here. They can still /// pick up additional connections via the borrow flow. class ProfileDetailScreen extends StatefulWidget { final Profile profile; const ProfileDetailScreen({super.key, required this.profile}); @override State createState() => _ProfileDetailScreenState(); } class _ProfileDetailScreenState extends State with ControllerDisposerMixin { late final TextEditingController _nameController = createTextEditingController(text: widget.profile.displayName); final _nameFocusNode = FocusNode(debugLabel: 'ProfileDetail:Name'); final _saveNameFocusNode = FocusNode(debugLabel: 'ProfileDetail:SaveName'); final _setPinFocusNode = FocusNode(debugLabel: 'ProfileDetail:SetPin'); final _changePinFocusNode = FocusNode(debugLabel: 'ProfileDetail:ChangePin'); final _addConnectionFocusNode = FocusNode(debugLabel: 'ProfileDetail:AddConnection'); final _deleteProfileFocusNode = FocusNode(debugLabel: 'ProfileDetail:DeleteProfile'); late Profile _profile; StreamSubscription>? _profileSub; @override void initState() { super.initState(); _profile = widget.profile; // Keep the snapshot live: the registry row can change underneath this // screen (rename from another surface, PIN cleared elsewhere) and the // header/PIN section would otherwise show stale state until reopened. if (_profile.isLocal) { _profileSub = context.read().watchProfiles().listen((locals) { Profile? updated; for (final p in locals) { if (p.id == _profile.id) { updated = p; break; } } if (updated == null || updated == _profile || !mounted) return; final namePristine = _nameController.text.trim() == _profile.displayName; setState(() { _profile = updated!; if (namePristine) _nameController.text = updated.displayName; }); }); } } @override void dispose() { _profileSub?.cancel(); _nameFocusNode.dispose(); _saveNameFocusNode.dispose(); _setPinFocusNode.dispose(); _changePinFocusNode.dispose(); _addConnectionFocusNode.dispose(); _deleteProfileFocusNode.dispose(); super.dispose(); } Future _saveName() async { final name = _nameController.text.trim(); if (name.isEmpty || name == _profile.displayName) return; final updated = _profile.copyWith(displayName: name); await context.read().upsert(updated); if (!mounted) return; setState(() => _profile = updated); showSuccessSnackBar(context, t.profiles.profileRenamed); } Future _setPin() async { final pin = await captureAndConfirmPin( context, onMismatch: (ctx) => showErrorSnackBar(ctx, t.profiles.pinsDontMatch), ); if (pin == null || !mounted) return; final profile = _profile; if (profile is! LocalProfile) return; final hadPin = profile.pinHash != null; final updated = profile.copyWith(pinHash: computePinHash(pin)); await context.read().upsert(updated); if (!mounted) return; setState(() => _profile = updated); if (!hadPin) { // The Set PIN button (and its focus node) just left the tree — hand // DPAD focus to the replacing row instead of dropping it. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _changePinFocusNode.requestFocus(); }); } } Future _clearPin() async { final profile = _profile; if (profile is! LocalProfile) return; final updated = profile.copyWith(pinHash: null); await context.read().upsert(updated); if (!mounted) return; setState(() => _profile = updated); // Reverse swap of _setPin: the row (and the focused Remove button) // just left the tree. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _setPinFocusNode.requestFocus(); }); } Future _addConnection() async { await Navigator.of(context).push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: _profile))); } Future _removeConnection(ProfileConnection pc, Connection conn) async { final confirmed = await showConfirmDialog( context, title: t.profiles.removeConnectionTitle, message: t.profiles.removeConnectionMessage( displayName: _profile.displayName, connectionLabel: conn.displayLabel, ), confirmText: t.profiles.removeConnection, isDestructive: true, ); if (!confirmed || !mounted) return; final scope = SessionTeardownScope.of(context); final endedOwner = scope.active.activeId == _profile.id ? _profile.id : null; if (endedOwner != null) { await scope.shelf.endProfileSession(endedOwner); } try { // Release downloads only for servers the profile actually loses — the // same server can stay reachable through another connection (a second // Plex account sharing the server, another Jellyfin user). final retainedServerIds = await _retainedServerIds( excludingConnectionId: conn.id, profileConnections: scope.profileConnections, connections: scope.connections, ); await scope.downloads.releaseDownloadsForProfileServers( _profile.id, _serverIdsForConnection(conn).difference(retainedServerIds), ); await scope.cleanup.removeProfileConnection(profileId: _profile.id, connection: conn); await scope.hiddenLibraries?.refresh(); // Deliberately not `resumeFreshSystemShelf`: a rebind failure on the // success path must reach the catch below so the recovery attempt — // and the rethrow — still run. await scope.binder.rebindIfActive(_profile.id); if (endedOwner != null && scope.active.activeId == endedOwner) { scope.shelf.beginProfileSession(endedOwner); if (scope.multiServer.hasConnectedServers) await scope.discover?.load(); } } catch (_) { if (endedOwner != null) { await resumeFreshSystemShelf(scope, endedOwner); } rethrow; } } /// Server ids the profile keeps after removing [excludingConnectionId]: /// its other join rows plus, for Plex Home profiles, the implicit parent /// account. Raw ids, matching the download keys this is differenced /// against; `_serverIdsForProfile` in profile_connection_cleanup.dart is /// ServerId-typed and ignores the parent, so the two are not the same /// projection. Future> _retainedServerIds({ required String excludingConnectionId, required ProfileConnectionRegistry profileConnections, required ConnectionRegistry connections, }) async { final rows = await profileConnections.listForProfile(_profile.id); final byId = {for (final c in await connections.list()) c.id: c}; final retained = {}; for (final row in rows) { if (row.connectionId == excludingConnectionId) continue; final other = byId[row.connectionId]; if (other != null) retained.addAll(_serverIdsForConnection(other)); } final parentId = _profile.isPlexHome ? _profile.parentConnectionId : null; if (parentId != null && parentId != excludingConnectionId) { final parent = byId[parentId]; if (parent != null) retained.addAll(_serverIdsForConnection(parent)); } return retained; } Future _editConnection(Connection conn) async { if (conn is! JellyfinConnection) return; final changed = await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => EditJellyfinConnectionScreen(connection: conn))); if (changed != true || !mounted) return; setState(() {}); unawaited(context.read().rebindIfActive(_profile.id)); } // Raw machine ids rather than the ServerId-typed twin in // profile_connection_cleanup.dart: these are differenced against retained // ids and matched to download global keys, which carry the unparsed id. Set _serverIdsForConnection(Connection conn) { return switch (conn) { PlexAccountConnection(:final servers) => servers.map((s) => s.clientIdentifier).toSet(), JellyfinConnection(:final serverMachineId) => {serverMachineId}, }; } /// Sign out of this virtual profile's parent Plex account. The profile /// ceases to exist with the account, so pop the detail screen — unless /// the teardown already reset the stack to AuthScreen (unmounted here). Future _signOutParentAccount(Connection parentConn) async { final signedOut = await confirmAndSignOutPlexAccount(context, accountConnectionId: parentConn.id); if (!signedOut || !mounted) return; Navigator.of(context).pop(true); } Future _deleteProfile() async { final deleted = await confirmAndDeleteProfile( context, profile: _profile, title: t.profiles.deleteProfileTitle, message: t.profiles.deleteProfileMessage(displayName: _profile.displayName), confirmText: t.common.delete, ); if (!deleted || !mounted) return; Navigator.of(context).pop(true); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isLocal = _profile.isLocal; return FocusedScrollScaffold( title: Text(_profile.displayName), slivers: [ SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), sliver: SliverList( delegate: SliverChildListDelegate([ Center(child: ProfileAvatar(profile: _profile, size: 96)), const SizedBox(height: 24), Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge), const SizedBox(height: 8), if (isLocal) ProfileNameField( controller: _nameController, focusNode: _nameFocusNode, onChanged: () => setState(() {}), onNavigateRight: _saveNameFocusNode.requestFocus, trailing: FocusableButton( focusNode: _saveNameFocusNode, onNavigateLeft: _nameFocusNode.requestFocus, onPressed: _nameController.text.trim().isEmpty || _nameController.text.trim() == _profile.displayName ? null : _saveName, child: FilledButton( onPressed: _nameController.text.trim().isEmpty || _nameController.text.trim() == _profile.displayName ? null : _saveName, child: Text(t.common.save), ), ), ) else Text( _profile.displayName, style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(height: 24), Text(t.profiles.pinProtectionLabel, style: theme.textTheme.labelLarge), const SizedBox(height: 8), if (!isLocal) Text( _profile.plexProtected ? t.profiles.pinManagedByPlex : t.profiles.noPinSetEditOnPlex, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ) else if (_profile.pinHash == null) FocusableButton( focusNode: _setPinFocusNode, onPressed: _setPin, child: OutlinedButton.icon( onPressed: _setPin, icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1), label: Text(t.profiles.setPin), ), ) else PinStatusRow(onChange: _setPin, onRemove: _clearPin, changeFocusNode: _changePinFocusNode), const SizedBox(height: 32), Row( children: [ Expanded(child: Text(t.profiles.connectionsLabel, style: theme.textTheme.labelLarge)), FocusableButton( focusNode: _addConnectionFocusNode, onPressed: _addConnection, child: TextButton.icon( onPressed: _addConnection, icon: const AppIcon(Symbols.add_rounded, fill: 1), label: Text(t.profiles.add), ), ), ], ), const SizedBox(height: 8), _ConnectionsList( profile: _profile, onRemove: _removeConnection, onEdit: _editConnection, onSignOutParent: _signOutParentAccount, ), const SizedBox(height: 24), if (isLocal) FocusableButton( focusNode: _deleteProfileFocusNode, onPressed: _deleteProfile, child: OutlinedButton.icon( onPressed: _deleteProfile, icon: AppIcon(Symbols.delete_outline_rounded, fill: 1, color: theme.colorScheme.error), label: Text(t.profiles.deleteProfileButton, style: TextStyle(color: theme.colorScheme.error)), ), ), ]), ), ), ], ); } } class _ConnectionsList extends StatefulWidget { final Profile profile; final Future Function(ProfileConnection pc, Connection conn) onRemove; final Future Function(Connection conn) onEdit; final Future Function(Connection conn) onSignOutParent; const _ConnectionsList({ required this.profile, required this.onRemove, required this.onEdit, required this.onSignOutParent, }); @override State<_ConnectionsList> createState() => _ConnectionsListState(); } class _ConnectionsListState extends State<_ConnectionsList> { // Created once: building streams/futures inside build re-subscribes and // refetches on every parent rebuild (each keystroke in the name field), // flashing the spinner and hammering the DB. Stream>? _pcsStream; Stream>>? _homeStream; Map>? _homeInitial; Stream>? _connectionsStream; @override void didChangeDependencies() { super.didChangeDependencies(); _pcsStream ??= context.read().watchForProfile(widget.profile.id); final plexHome = context.read(); _homeStream ??= plexHome.stream; _homeInitial ??= plexHome.current; _connectionsStream ??= context.read().watchConnections(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final profile = widget.profile; final pcRegistry = context.read(); return StreamBuilder>( stream: _pcsStream, builder: (context, snapshot) { final pcs = snapshot.data ?? const []; if (snapshot.connectionState == ConnectionState.waiting) { return const Padding( padding: .symmetric(vertical: 20), child: Center(child: CircularProgressIndicator()), ); } return StreamBuilder>>( stream: _homeStream, initialData: _homeInitial, builder: (context, homeSnap) { final homeCache = homeSnap.data ?? const >{}; return StreamBuilder>( stream: _connectionsStream, builder: (context, snap) { final all = snap.data ?? const []; final byId = {for (final c in all) c.id: c}; // Plex Home profiles have an implicit parent connection that // isn't in the join table — list it first so the user sees the // full picture. The profile *is* a home user of that account, // so the only removal is signing out of the whole account; // it isn't shown for locals. final parentConn = profile.isPlexHome ? byId[profile.parentConnectionId] : null; final visiblePcs = visibleProfileConnections(profile, pcs); if (visiblePcs.isEmpty && parentConn == null) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text( t.profiles.noConnectionsHint, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), ), ); } // The screen already pads its content; SettingsGroup supplies // the M3E connected-group card geometry. return SettingsGroup( margin: EdgeInsets.zero, children: [ if (parentConn != null) ListTile( leading: BackendBadge(backend: parentConn.backend, size: 24), title: Text(parentConn.displayLabel), subtitle: Text(t.profiles.plexHomeAccount), trailing: FocusablePopupMenuButton( icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), tooltip: t.profiles.manage, onSelected: (value) { if (value == 'sign_out') { unawaited(widget.onSignOutParent(parentConn)); } }, itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)], ), ), for (final pc in visiblePcs) if (byId[pc.connectionId] case final conn?) ListTile( leading: BackendBadge(backend: conn.backend, size: 24), title: Text(conn.displayLabel), subtitle: _ConnectionSubtitle.build(conn: conn, pc: pc, homeCache: homeCache, theme: theme), trailing: FocusablePopupMenuButton( icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), tooltip: t.profiles.manage, onSelected: (value) { if (value == 'default') { unawaited(pcRegistry.setDefault(profile.id, pc.connectionId)); } else if (value == 'edit') { unawaited(widget.onEdit(conn)); } else if (value == 'remove') { unawaited(widget.onRemove(pc, conn)); } }, itemBuilder: (_) => [ if (!pc.isDefault) AppMenuItem(value: 'default', label: t.profiles.makeDefault), if (conn is JellyfinConnection) AppMenuItem(value: 'edit', label: t.common.edit), AppMenuItem(value: 'remove', label: t.profiles.removeConnection), ], ), ), ], ); }, ); }, ); }, ); } } /// Renders the "as {homeUser} · Default" sub-line under each connection /// row. The home-user lookup turns the bare account label (e.g. the /// owner's email) into something the user can match to the picker — /// otherwise borrowed-from-different-home connections look identical. class _ConnectionSubtitle { static Widget? build({ required Connection conn, required ProfileConnection pc, required Map> homeCache, required ThemeData theme, }) { final parts = []; if (conn is PlexAccountConnection) { final users = homeCache[conn.id]; if (users != null) { final user = users.where((u) => u.uuid == pc.userIdentifier).firstOrNull; if (user != null) parts.add(t.profiles.connectionAs(displayName: user.displayName)); } } if (pc.isDefault) parts.add(t.profiles.connectionDefault); if (parts.isEmpty) return null; return Text(parts.join(' · ')); } }