Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
531 lines
22 KiB
Dart
531 lines
22 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.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<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 _changePinFocusNode = FocusNode(debugLabel: 'ProfileDetail:ChangePin');
|
|
final _addConnectionFocusNode = FocusNode(debugLabel: 'ProfileDetail:AddConnection');
|
|
final _deleteProfileFocusNode = FocusNode(debugLabel: 'ProfileDetail:DeleteProfile');
|
|
late Profile _profile;
|
|
StreamSubscription<List<Profile>>? _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<ProfileRegistry>().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<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 hadPin = profile.pinHash != null;
|
|
final updated = profile.copyWith(pinHash: computePinHash(pin));
|
|
await context.read<ProfileRegistry>().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<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);
|
|
// Reverse swap of _setPin: the row (and the focused Remove button)
|
|
// just left the tree.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _setPinFocusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
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;
|
|
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<Set<String>> _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 = <String>{};
|
|
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<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));
|
|
}
|
|
|
|
// 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<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, 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<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
|
|
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<List<ProfileConnection>>? _pcsStream;
|
|
Stream<Map<String, List<PlexHomeUser>>>? _homeStream;
|
|
Map<String, List<PlexHomeUser>>? _homeInitial;
|
|
Stream<List<Connection>>? _connectionsStream;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_pcsStream ??= context.read<ProfileConnectionRegistry>().watchForProfile(widget.profile.id);
|
|
final plexHome = context.read<PlexHomeService>();
|
|
_homeStream ??= plexHome.stream;
|
|
_homeInitial ??= plexHome.current;
|
|
_connectionsStream ??= context.read<ConnectionRegistry>().watchConnections();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final profile = widget.profile;
|
|
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
|
|
|
return StreamBuilder<List<ProfileConnection>>(
|
|
stream: _pcsStream,
|
|
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: _homeStream,
|
|
initialData: _homeInitial,
|
|
builder: (context, homeSnap) {
|
|
final homeCache = homeSnap.data ?? const <String, List<PlexHomeUser>>{};
|
|
return StreamBuilder<List<Connection>>(
|
|
stream: _connectionsStream,
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
// 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<String>(
|
|
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<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(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<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(' · '));
|
|
}
|
|
}
|