428 lines
18 KiB
Dart
428 lines
18 KiB
Dart
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_cleanup.dart';
|
|
import '../../profiles/profile_connection.dart';
|
|
import '../../profiles/profile_connection_registry.dart';
|
|
import '../../profiles/profile_registry.dart';
|
|
import '../../profiles/profiles_view.dart';
|
|
import '../../providers/download_provider.dart';
|
|
import '../../providers/hidden_libraries_provider.dart';
|
|
import '../../providers/multi_server_provider.dart';
|
|
import '../../services/storage_service.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 '../../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<ProfileDetailScreen> createState() => _ProfileDetailScreenState();
|
|
}
|
|
|
|
class _ProfileDetailScreenState extends State<ProfileDetailScreen> 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 _addConnectionFocusNode = FocusNode(debugLabel: 'ProfileDetail:AddConnection');
|
|
final _deleteProfileFocusNode = FocusNode(debugLabel: 'ProfileDetail:DeleteProfile');
|
|
late Profile _profile;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_profile = widget.profile;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameFocusNode.dispose();
|
|
_saveNameFocusNode.dispose();
|
|
_setPinFocusNode.dispose();
|
|
_addConnectionFocusNode.dispose();
|
|
_deleteProfileFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _saveName() async {
|
|
final name = _nameController.text.trim();
|
|
if (name.isEmpty || name == _profile.displayName) return;
|
|
final updated = _profile.copyWith(displayName: name);
|
|
await context.read<ProfileRegistry>().upsert(updated);
|
|
if (!mounted) return;
|
|
setState(() => _profile = updated);
|
|
showSuccessSnackBar(context, t.profiles.profileRenamed);
|
|
}
|
|
|
|
Future<void> _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 updated = profile.copyWith(pinHash: computePinHash(pin));
|
|
await context.read<ProfileRegistry>().upsert(updated);
|
|
if (!mounted) return;
|
|
setState(() => _profile = updated);
|
|
}
|
|
|
|
Future<void> _clearPin() async {
|
|
final profile = _profile;
|
|
if (profile is! LocalProfile) return;
|
|
final updated = profile.copyWith(pinHash: null);
|
|
await context.read<ProfileRegistry>().upsert(updated);
|
|
if (!mounted) return;
|
|
setState(() => _profile = updated);
|
|
}
|
|
|
|
Future<void> _addConnection() async {
|
|
await Navigator.of(context).push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: _profile)));
|
|
}
|
|
|
|
Future<void> _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;
|
|
await context.read<DownloadProvider>().releaseDownloadsForProfileServers(
|
|
_profile.id,
|
|
_serverIdsForConnection(conn),
|
|
);
|
|
if (!mounted) return;
|
|
await removeProfileConnectionAndCleanup(
|
|
profileId: _profile.id,
|
|
connection: conn,
|
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
|
connections: context.read<ConnectionRegistry>(),
|
|
storage: context.read<StorageService>(),
|
|
serverManager: context.read<MultiServerProvider>().serverManager,
|
|
);
|
|
if (!mounted) return;
|
|
await context.read<HiddenLibrariesProvider?>()?.refresh();
|
|
if (!mounted) return;
|
|
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(_profile.id));
|
|
}
|
|
|
|
Future<void> _editConnection(Connection conn) async {
|
|
if (conn is! JellyfinConnection) return;
|
|
final changed = await Navigator.of(
|
|
context,
|
|
).push<bool>(MaterialPageRoute(builder: (_) => EditJellyfinConnectionScreen(connection: conn)));
|
|
if (changed != true || !mounted) return;
|
|
setState(() {});
|
|
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(_profile.id));
|
|
}
|
|
|
|
Set<String> _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<void> _signOutParentAccount(Connection parentConn) async {
|
|
final signedOut = await confirmAndSignOutPlexAccount(context, accountConnectionId: parentConn.id);
|
|
if (!signedOut || !mounted) return;
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
|
|
Future<void> _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),
|
|
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 StatelessWidget {
|
|
final Profile profile;
|
|
final Future<void> Function(ProfileConnection pc, Connection conn) onRemove;
|
|
final Future<void> Function(Connection conn) onEdit;
|
|
final Future<void> Function(Connection conn) onSignOutParent;
|
|
|
|
const _ConnectionsList({
|
|
required this.profile,
|
|
required this.onRemove,
|
|
required this.onEdit,
|
|
required this.onSignOutParent,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
|
final connRegistry = context.read<ConnectionRegistry>();
|
|
final plexHome = context.read<PlexHomeService>();
|
|
|
|
return StreamBuilder<List<ProfileConnection>>(
|
|
stream: pcRegistry.watchForProfile(profile.id),
|
|
builder: (context, snapshot) {
|
|
final pcs = snapshot.data ?? const <ProfileConnection>[];
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Padding(
|
|
padding: .symmetric(vertical: 20),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
return StreamBuilder<Map<String, List<PlexHomeUser>>>(
|
|
stream: plexHome.stream,
|
|
initialData: plexHome.current,
|
|
builder: (context, homeSnap) {
|
|
final homeCache = homeSnap.data ?? const <String, List<PlexHomeUser>>{};
|
|
return FutureBuilder<List<Connection>>(
|
|
future: connRegistry.list(),
|
|
builder: (context, snap) {
|
|
final all = snap.data ?? const <Connection>[];
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
return Column(
|
|
children: [
|
|
if (parentConn != null)
|
|
Card(
|
|
child: ListTile(
|
|
leading: BackendBadge(backend: parentConn.backend, size: 24),
|
|
title: Text(parentConn.displayLabel),
|
|
subtitle: Text(t.profiles.plexHomeAccount),
|
|
trailing: FocusablePopupMenuButton<String>(
|
|
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
|
tooltip: t.profiles.manage,
|
|
onSelected: (value) {
|
|
if (value == 'sign_out') {
|
|
unawaited(onSignOutParent(parentConn));
|
|
}
|
|
},
|
|
itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)],
|
|
),
|
|
),
|
|
),
|
|
for (final pc in visiblePcs)
|
|
if (byId[pc.connectionId] case final conn?)
|
|
Card(
|
|
child: 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<String>(
|
|
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(onEdit(conn));
|
|
} else if (value == 'remove') {
|
|
unawaited(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<String, List<PlexHomeUser>> homeCache,
|
|
required ThemeData theme,
|
|
}) {
|
|
final parts = <String>[];
|
|
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(' · '));
|
|
}
|
|
}
|