fix: improve d-pad focus navigation

This commit is contained in:
edde746
2026-05-07 00:27:58 +02:00
parent ebec3ad20c
commit 7988c83bec
40 changed files with 1458 additions and 497 deletions
+21 -8
View File
@@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../i18n/strings.g.dart';
import '../../services/plex_auth_service.dart';
import '../../focus/focusable_button.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_detector.dart';
@@ -222,9 +223,15 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton(onPressed: busy ? null : browser, child: Text(t.auth.signInWithPlex)),
FocusableButton(
onPressed: busy ? null : browser,
child: FilledButton(onPressed: busy ? null : browser, child: Text(t.auth.signInWithPlex)),
),
const SizedBox(height: 12),
OutlinedButton(onPressed: busy ? null : qr, child: Text(t.auth.showQRCode)),
FocusableButton(
onPressed: busy ? null : qr,
child: OutlinedButton(onPressed: busy ? null : qr, child: Text(t.auth.showQRCode)),
),
],
);
}
@@ -251,10 +258,13 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
),
),
const SizedBox(height: 24),
OutlinedButton(
FocusableButton(
onPressed: _retry,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
child: Text(t.common.retry),
child: OutlinedButton(
onPressed: _retry,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
child: Text(t.common.retry),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
@@ -280,10 +290,13 @@ class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
OutlinedButton(
FocusableButton(
onPressed: _retry,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
child: Text(t.common.retry),
child: OutlinedButton(
onPressed: _retry,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
child: Text(t.common.retry),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
+82 -65
View File
@@ -17,6 +17,7 @@ import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/key_event_utils.dart';
import '../media/media_backend.dart';
import '../utils/navigation_transitions.dart';
import '../widgets/backend_badge.dart';
@@ -203,61 +204,65 @@ class _AuthScreenState extends State<AuthScreen> {
// Use two-column layout on desktop, single column on mobile
final isDesktop = MediaQuery.sizeOf(context).width > 700;
return Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: const EdgeInsets.all(24),
child: isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
return Focus(
canRequestFocus: false,
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
child: Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: const EdgeInsets.all(24),
child: isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
),
),
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildAuthBody()],
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildAuthBody()],
),
),
),
),
),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
),
),
),
),
),
),
);
@@ -322,17 +327,23 @@ class _AuthScreenState extends State<AuthScreen> {
),
],
] else ...[
ElevatedButton.icon(
FocusableButton(
onPressed: busy ? null : startBrowser,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
label: Text(t.auth.signInWithPlex),
child: ElevatedButton.icon(
onPressed: busy ? null : startBrowser,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
label: Text(t.auth.signInWithPlex),
),
),
const SizedBox(height: 12),
OutlinedButton(
FocusableButton(
onPressed: busy ? null : startQr,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.showQRCode),
child: OutlinedButton(
onPressed: busy ? null : startQr,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.showQRCode),
),
),
],
const SizedBox(height: 24),
@@ -363,21 +374,27 @@ class _AuthScreenState extends State<AuthScreen> {
),
)
else
OutlinedButton.icon(
FocusableButton(
onPressed: _connectToJellyfin,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
label: Text(t.auth.connectToJellyfin),
child: OutlinedButton.icon(
onPressed: _connectToJellyfin,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
label: Text(t.auth.connectToJellyfin),
),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
OutlinedButton(
FocusableButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
child: OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
),
child: const Text('Debug: Enter Plex Token', style: TextStyle(fontSize: 12)),
),
child: const Text('Debug: Enter Plex Token', style: TextStyle(fontSize: 12)),
),
],
if (_errorMessage != null) ...[
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../media/media_filter.dart';
import '../../services/plex_client.dart';
import '../../utils/scroll_utils.dart';
@@ -228,15 +229,23 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
title: t.libraries.filters,
leading: const AppIcon(Symbols.filter_alt_rounded, fill: 1),
action: _tempSelectedFilters.isNotEmpty
? TextButton.icon(
? FocusableButton(
onPressed: () {
setState(() {
_tempSelectedFilters.clear();
});
_applyFilters();
},
icon: const AppIcon(Symbols.clear_all_rounded, fill: 1),
label: Text(t.libraries.clearAll),
child: TextButton.icon(
onPressed: () {
setState(() {
_tempSelectedFilters.clear();
});
_applyFilters();
},
icon: const AppIcon(Symbols.clear_all_rounded, fill: 1),
label: Text(t.libraries.clearAll),
),
)
: null,
),
+7 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focusable_button.dart';
import '../../focus/input_mode_tracker.dart';
import '../../media/media_sort.dart';
import '../../utils/scroll_utils.dart';
@@ -107,7 +108,12 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
children: [
BottomSheetHeader(
title: t.libraries.sortBy,
action: widget.onClear != null ? TextButton(onPressed: _handleClear, child: Text(t.common.clear)) : null,
action: widget.onClear != null
? FocusableButton(
onPressed: _handleClear,
child: TextButton(onPressed: _handleClear, child: Text(t.common.clear)),
)
: null,
),
Flexible(
child: RadioGroup<MediaSort>(
+8 -3
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
/// Base widget for displaying state messages (empty, error, etc.)
/// Provides a consistent UI pattern for showing icons, messages, and actions
class StateMessageWidget extends StatelessWidget {
@@ -84,10 +86,13 @@ class StateMessageWidget extends StatelessWidget {
],
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: 24),
FilledButton.icon(
FocusableButton(
onPressed: onAction,
icon: AppIcon(actionIcon ?? Symbols.refresh_rounded, fill: 1),
label: Text(actionLabel!),
child: FilledButton.icon(
onPressed: onAction,
icon: AppIcon(actionIcon ?? Symbols.refresh_rounded, fill: 1),
label: Text(actionLabel!),
),
),
],
],
+8 -3
View File
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../media/live_tv_support.dart';
import '../../media/media_server_client.dart';
@@ -606,10 +607,14 @@ class _LiveTvScreenState extends State<LiveTvScreen>
const SizedBox(height: 16),
Text(_error!, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.icon(
FocusableButton(
autofocus: true,
onPressed: _loadChannels,
icon: const AppIcon(Symbols.refresh_rounded),
label: Text(t.common.retry),
child: FilledButton.icon(
onPressed: _loadChannels,
icon: const AppIcon(Symbols.refresh_rounded),
label: Text(t.common.retry),
),
),
],
),
@@ -509,6 +509,8 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
autofocus: widget.autofocus,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))],
onNavigateUp: () => FocusScope.of(context).previousFocus(),
onNavigateDown: () => FocusScope.of(context).nextFocus(),
onChanged: (text) {
final parsed = int.tryParse(text);
widget.onChanged(parsed);
@@ -571,6 +573,8 @@ class _TextSettingRowState extends State<_TextSettingRow> with ControllerDispose
FocusableTextField(
controller: _controller,
autofocus: widget.autofocus,
onNavigateUp: () => FocusScope.of(context).previousFocus(),
onNavigateDown: () => FocusScope.of(context).nextFocus(),
onChanged: (text) => widget.onChanged(text),
),
],
@@ -3,13 +3,14 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../profiles/profile.dart';
import '../../profiles/profile_registry.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../settings/add_connection_screen.dart';
import 'pin_entry_dialog.dart';
import 'pin_status_row.dart';
@@ -30,9 +31,22 @@ class AddLocalProfileScreen extends StatefulWidget {
class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with ControllerDisposerMixin {
late final TextEditingController _nameController = createTextEditingController();
final _nameFocus = FocusNode(debugLabel: 'AddLocalProfile:Name');
final _setPinFocus = FocusNode(debugLabel: 'AddLocalProfile:SetPin');
final _continueFocus = FocusNode(debugLabel: 'AddLocalProfile:Continue');
final _cancelFocus = FocusNode(debugLabel: 'AddLocalProfile:Cancel');
String? _pinHash;
bool _saving = false;
@override
void dispose() {
_nameFocus.dispose();
_setPinFocus.dispose();
_continueFocus.dispose();
_cancelFocus.dispose();
super.dispose();
}
Future<void> _setPin() async {
final pin = await captureAndConfirmPin(
context,
@@ -73,49 +87,72 @@ class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with Cont
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: CustomScrollView(
slivers: [
ExcludeFocus(child: CustomAppBar(title: Text(t.profiles.newProfile), pinned: true)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
ProfileNameField(
controller: _nameController,
hintText: t.profiles.profileNameHint,
onChanged: () => setState(() {}),
),
const SizedBox(height: 24),
Text(t.profiles.pinProtectionOptional, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Text(
t.profiles.pinExplain,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
if (_pinHash == null)
OutlinedButton.icon(
return FocusedScrollScaffold(
title: Text(t.profiles.newProfile),
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
ProfileNameField(
controller: _nameController,
focusNode: _nameFocus,
hintText: t.profiles.profileNameHint,
onChanged: () => setState(() {}),
onNavigateDown: () => (_pinHash == null ? _setPinFocus : _continueFocus).requestFocus(),
),
const SizedBox(height: 24),
Text(t.profiles.pinProtectionOptional, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Text(
t.profiles.pinExplain,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
if (_pinHash == null)
FocusableButton(
focusNode: _setPinFocus,
useBackgroundFocus: true,
onPressed: _setPin,
onNavigateUp: () => _nameFocus.requestFocus(),
onNavigateDown: () => _continueFocus.requestFocus(),
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),
FilledButton(
),
)
else
PinStatusRow(onChange: _setPin, onRemove: _clearPin),
const SizedBox(height: 32),
FocusableButton(
focusNode: _continueFocus,
useBackgroundFocus: true,
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
onNavigateUp: () => (_pinHash == null ? _setPinFocus : _nameFocus).requestFocus(),
onNavigateDown: () => _cancelFocus.requestFocus(),
child: FilledButton(
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
child: Text(t.profiles.continueButton),
),
const SizedBox(height: 8),
TextButton(onPressed: _saving ? null : () => Navigator.of(context).pop(), child: Text(t.common.cancel)),
]),
),
),
const SizedBox(height: 8),
FocusableButton(
focusNode: _cancelFocus,
useBackgroundFocus: true,
onPressed: _saving ? null : () => Navigator.of(context).pop(),
onNavigateUp: () => _continueFocus.requestFocus(),
child: TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
),
]),
),
],
),
),
],
);
}
}
@@ -24,7 +24,7 @@ import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/loading_indicator_box.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../libraries/state_messages.dart';
import 'pin_entry_dialog.dart';
@@ -156,57 +156,50 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<List<_BorrowCandidate>>(
future: _candidatesFuture,
builder: (context, snapshot) {
final candidates = snapshot.data ?? const <_BorrowCandidate>[];
return CustomScrollView(
slivers: [
ExcludeFocus(
child: CustomAppBar(
title: Text(t.profiles.borrowAddTo(displayName: widget.targetProfile.displayName)),
pinned: true,
),
return FutureBuilder<List<_BorrowCandidate>>(
future: _candidatesFuture,
builder: (context, snapshot) {
final candidates = snapshot.data ?? const <_BorrowCandidate>[];
return FocusedScrollScaffold(
title: Text(t.profiles.borrowAddTo(displayName: widget.targetProfile.displayName)),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
sliver: SliverToBoxAdapter(
child: Text(t.profiles.borrowExplain, style: Theme.of(context).textTheme.bodySmall),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
sliver: SliverToBoxAdapter(
child: Text(t.profiles.borrowExplain, style: Theme.of(context).textTheme.bodySmall),
),
if (snapshot.connectionState != ConnectionState.done)
LoadingIndicatorBox.sliver
else if (candidates.isEmpty)
SliverFillRemaining(
child: EmptyStateWidget(
message: t.profiles.borrowEmpty,
subtitle: t.profiles.borrowEmptySubtitle,
icon: Symbols.share_rounded,
iconSize: 48,
),
),
if (snapshot.connectionState != ConnectionState.done)
LoadingIndicatorBox.sliver
else if (candidates.isEmpty)
SliverFillRemaining(
child: EmptyStateWidget(
message: t.profiles.borrowEmpty,
subtitle: t.profiles.borrowEmptySubtitle,
icon: Symbols.share_rounded,
iconSize: 48,
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final cand = candidates[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: FocusableWrapper(
autofocus: index == 0,
disableScale: true,
onSelect: _busy ? null : () => _borrow(cand),
child: Card(
child: _BorrowTile(candidate: cand, onTap: () => _borrow(cand)),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final cand = candidates[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: FocusableWrapper(
autofocus: index == 0,
disableScale: true,
onSelect: _busy ? null : () => _borrow(cand),
child: Card(
child: _BorrowTile(candidate: cand, onTap: () => _borrow(cand)),
),
);
}, childCount: candidates.length),
),
],
);
},
),
),
);
}, childCount: candidates.length),
),
],
);
},
);
}
+9 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../widgets/app_icon.dart';
/// "PIN set" pill + Change/Remove text buttons. Shown on profile creation
@@ -32,8 +33,14 @@ class PinStatusRow extends StatelessWidget {
),
),
const SizedBox(width: 12),
TextButton(onPressed: onChange, child: const Text('Change')),
TextButton(onPressed: onRemove, child: const Text('Remove')),
FocusableButton(
onPressed: onChange,
child: TextButton(onPressed: onChange, child: const Text('Change')),
),
FocusableButton(
onPressed: onRemove,
child: TextButton(onPressed: onRemove, child: const Text('Remove')),
),
],
);
}
+101 -66
View File
@@ -19,9 +19,11 @@ import '../../profiles/profile_registry.dart';
import '../../profiles/profiles_view.dart';
import '../../providers/download_provider.dart';
import '../../utils/snackbar_helper.dart';
import '../../focus/focusable_button.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/desktop_app_bar.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 'pin_entry_dialog.dart';
@@ -46,6 +48,11 @@ class ProfileDetailScreen extends StatefulWidget {
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
@@ -54,6 +61,16 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
_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;
@@ -130,76 +147,96 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
final theme = Theme.of(context);
final isLocal = _profile.isLocal;
return Scaffold(
body: CustomScrollView(
slivers: [
ExcludeFocus(child: CustomAppBar(title: Text(_profile.displayName), pinned: true)),
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,
onChanged: () => setState(() {}),
trailing: FilledButton(
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)
OutlinedButton.icon(
)
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)),
TextButton.icon(
),
)
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),
const SizedBox(height: 24),
if (isLocal)
OutlinedButton.icon(
),
],
),
const SizedBox(height: 8),
_ConnectionsList(profile: _profile, onRemove: _removeConnection),
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)),
),
]),
),
),
]),
),
],
),
),
],
);
}
}
@@ -269,21 +306,19 @@ class _ConnectionsList extends StatelessWidget {
leading: BackendBadge(backend: conn.backend, size: 24),
title: Text(conn.displayLabel),
subtitle: _ConnectionSubtitle.build(conn: conn, pc: pc, homeCache: homeCache, theme: theme),
trailing: PopupMenuButton<String>(
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 == 'remove') {
unawaited(onRemove(pc, conn));
}
},
itemBuilder: (_) => [
if (!pc.isDefault)
PopupMenuItem(
value: 'default',
onTap: () => WidgetsBinding.instance.addPostFrameCallback(
(_) => pcRegistry.setDefault(profile.id, pc.connectionId),
),
child: Text(t.profiles.makeDefault),
),
PopupMenuItem(
value: 'remove',
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onRemove(pc, conn)),
child: Text(t.profiles.removeConnection),
),
if (!pc.isDefault) PopupMenuItem(value: 'default', child: Text(t.profiles.makeDefault)),
PopupMenuItem(value: 'remove', child: Text(t.profiles.removeConnection)),
],
),
),
+25 -1
View File
@@ -6,20 +6,44 @@ import '../../focus/focusable_text_field.dart';
/// the profile-detail rename row. Optional [trailing] slot for an inline Save
/// button — pass `null` when the screen saves elsewhere (e.g. on Continue).
class ProfileNameField extends StatelessWidget {
const ProfileNameField({super.key, required this.controller, this.hintText, this.trailing, this.onChanged});
const ProfileNameField({
super.key,
required this.controller,
this.focusNode,
this.hintText,
this.trailing,
this.onChanged,
this.autofocus = false,
this.onNavigateUp,
this.onNavigateDown,
this.onNavigateLeft,
this.onNavigateRight,
});
final TextEditingController controller;
final FocusNode? focusNode;
final String? hintText;
final Widget? trailing;
final VoidCallback? onChanged;
final bool autofocus;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
@override
Widget build(BuildContext context) {
final field = FocusableTextField(
controller: controller,
focusNode: focusNode,
autofocus: autofocus,
textInputAction: TextInputAction.done,
decoration: InputDecoration(hintText: hintText, border: const OutlineInputBorder()),
onChanged: (_) => onChanged?.call(),
onNavigateUp: onNavigateUp ?? () => FocusScope.of(context).previousFocus(),
onNavigateDown: onNavigateDown ?? () => FocusScope.of(context).nextFocus(),
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
);
if (trailing == null) return field;
return Row(
+139 -31
View File
@@ -26,6 +26,7 @@ import '../../utils/dialogs.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/focusable_popup_menu_button.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../libraries/state_messages.dart';
import '../auth_screen.dart';
@@ -50,7 +51,9 @@ class ProfileSwitchScreen extends StatefulWidget {
class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedSetStateMixin {
bool _allowPop = false;
final FocusNode _firstSelectableFocusNode = FocusNode();
final Map<String, FocusNode> _profileFocusNodes = {};
final Map<String, FocusNode> _profileMenuFocusNodes = {};
final Map<String, GlobalKey<PopupMenuButtonState<_TileAction>>> _profileMenuKeys = {};
bool _focusRequested = false;
bool _switching = false;
Stream<ProfilesView>? _viewStream;
@@ -77,7 +80,12 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
@override
void dispose() {
_firstSelectableFocusNode.dispose();
for (final node in _profileFocusNodes.values) {
node.dispose();
}
for (final node in _profileMenuFocusNodes.values) {
node.dispose();
}
super.dispose();
}
@@ -95,6 +103,7 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
initialData: ProfilesView.empty,
builder: (context, snapshot) {
final view = snapshot.data ?? ProfilesView.empty;
_pruneProfileFocusResources(view.profiles.map((p) => p.id).toSet());
// `context.select` only rebuilds when `activeId` actually
// changes. `context.watch` would rebuild on every provider
// notification — combined with the stream, that doubles the
@@ -122,6 +131,9 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
sliver: SliverToBoxAdapter(
child: FocusableWrapper(
disableScale: true,
borderRadius: 100,
useBackgroundFocus: true,
descendantsAreFocusable: false,
onSelect: _switching ? null : _addLocalProfile,
child: OutlinedButton.icon(
onPressed: _switching ? null : _addLocalProfile,
@@ -167,6 +179,40 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
);
}
FocusNode _profileFocusNode(Profile profile) {
return _profileFocusNodes.putIfAbsent(profile.id, () => FocusNode(debugLabel: 'ProfileTile:${profile.id}'));
}
FocusNode _profileMenuFocusNode(Profile profile) {
return _profileMenuFocusNodes.putIfAbsent(profile.id, () => FocusNode(debugLabel: 'ProfileActions:${profile.id}'));
}
GlobalKey<PopupMenuButtonState<_TileAction>> _profileMenuKey(Profile profile) {
return _profileMenuKeys.putIfAbsent(profile.id, () => GlobalKey<PopupMenuButtonState<_TileAction>>());
}
void _pruneProfileFocusResources(Set<String> activeIds) {
for (final id in _profileFocusNodes.keys.toList()) {
if (!activeIds.contains(id)) {
_profileFocusNodes.remove(id)?.dispose();
}
}
for (final id in _profileMenuFocusNodes.keys.toList()) {
if (!activeIds.contains(id)) {
_profileMenuFocusNodes.remove(id)?.dispose();
}
}
for (final id in _profileMenuKeys.keys.toList()) {
if (!activeIds.contains(id)) {
_profileMenuKeys.remove(id);
}
}
}
void _openProfileMenu(Profile profile) {
_profileMenuKeys[profile.id]?.currentState?.showButtonMenu();
}
List<Widget> _buildSections(ProfilesView view, String? activeId) {
return [_profileList(view.profiles, view, activeId, autofocusFirst: true)];
}
@@ -177,11 +223,20 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
final profile = profiles[index];
final isActive = profile.id == activeId;
final isFirstSelectable = autofocusFirst && index == 0;
final profileFocusNode = _profileFocusNode(profile);
final menuFocusNode = _profileMenuFocusNode(profile);
final menuKey = _profileMenuKey(profile);
final onManage = !widget.requireSelection ? () => _manageProfile(profile) : null;
final onDelete = profile.isLocal && !widget.requireSelection ? () => _deleteProfile(profile) : null;
final onSignOut = profile.isPlexHome && profile.parentConnectionId != null && !widget.requireSelection
? () => _signOutPlexAccount(profile)
: null;
final hasMenu = onManage != null || onDelete != null || onSignOut != null;
if (isFirstSelectable && !_focusRequested) {
_focusRequested = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _firstSelectableFocusNode.requestFocus();
if (mounted) profileFocusNode.requestFocus();
});
}
@@ -189,8 +244,11 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: FocusableWrapper(
autofocus: isFirstSelectable,
focusNode: isFirstSelectable ? _firstSelectableFocusNode : null,
focusNode: profileFocusNode,
disableScale: true,
enableLongPress: hasMenu,
onLongPress: hasMenu ? () => _openProfileMenu(profile) : null,
onNavigateRight: hasMenu ? () => menuFocusNode.requestFocus() : null,
onSelect: _switching || (isActive && !widget.requireSelection) ? null : () => _switchTo(profile),
child: Card(
child: _ProfileTile(
@@ -201,11 +259,12 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
// Manage available for any profile — adding/removing
// borrowed connections is supported on plex_home too. Delete
// stays local-only (Plex Home users are owned by Plex).
onManage: !widget.requireSelection ? () => _manageProfile(profile) : null,
onDelete: profile.isLocal && !widget.requireSelection ? () => _deleteProfile(profile) : null,
onSignOut: profile.isPlexHome && profile.parentConnectionId != null && !widget.requireSelection
? () => _signOutPlexAccount(profile)
: null,
onManage: onManage,
onDelete: onDelete,
onSignOut: onSignOut,
menuFocusNode: menuFocusNode,
menuKey: menuKey,
onMenuNavigateLeft: () => profileFocusNode.requestFocus(),
),
),
),
@@ -365,6 +424,9 @@ class _ProfileTile extends StatelessWidget {
final VoidCallback? onManage;
final VoidCallback? onDelete;
final VoidCallback? onSignOut;
final FocusNode menuFocusNode;
final GlobalKey<PopupMenuButtonState<_TileAction>> menuKey;
final VoidCallback onMenuNavigateLeft;
const _ProfileTile({
required this.profile,
@@ -374,6 +436,9 @@ class _ProfileTile extends StatelessWidget {
this.onManage,
this.onDelete,
this.onSignOut,
required this.menuFocusNode,
required this.menuKey,
required this.onMenuNavigateLeft,
});
@override
@@ -425,28 +490,15 @@ class _ProfileTile extends StatelessWidget {
),
),
if (hasMenu)
PopupMenuButton<_TileAction>(
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
tooltip: 'Profile actions',
itemBuilder: (_) => [
if (onManage != null)
PopupMenuItem(
value: _TileAction.manage,
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onManage?.call()),
child: Text(t.profiles.manage),
),
if (onDelete != null)
PopupMenuItem(
value: _TileAction.delete,
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onDelete?.call()),
child: Text(t.profiles.delete),
),
if (onSignOut != null)
PopupMenuItem(
value: _TileAction.signOut,
onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onSignOut?.call()),
child: Text(t.profiles.signOut),
),
_ProfileActionsButton(
menuKey: menuKey,
focusNode: menuFocusNode,
onNavigateLeft: onMenuNavigateLeft,
onSelected: _handleAction,
actions: [
if (onManage != null) _TileAction.manage,
if (onDelete != null) _TileAction.delete,
if (onSignOut != null) _TileAction.signOut,
],
)
else if (!isActive)
@@ -456,6 +508,62 @@ class _ProfileTile extends StatelessWidget {
),
);
}
void _handleAction(_TileAction action) {
WidgetsBinding.instance.addPostFrameCallback((_) {
switch (action) {
case _TileAction.manage:
onManage?.call();
break;
case _TileAction.delete:
onDelete?.call();
break;
case _TileAction.signOut:
onSignOut?.call();
break;
}
});
}
}
class _ProfileActionsButton extends StatelessWidget {
final GlobalKey<PopupMenuButtonState<_TileAction>> menuKey;
final FocusNode focusNode;
final VoidCallback onNavigateLeft;
final ValueChanged<_TileAction> onSelected;
final List<_TileAction> actions;
const _ProfileActionsButton({
required this.menuKey,
required this.focusNode,
required this.onNavigateLeft,
required this.onSelected,
required this.actions,
});
@override
Widget build(BuildContext context) {
return FocusablePopupMenuButton<_TileAction>(
menuKey: menuKey,
focusNode: focusNode,
semanticLabel: t.profiles.manage,
onNavigateLeft: onNavigateLeft,
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
tooltip: t.profiles.manage,
onSelected: onSelected,
itemBuilder: (_) => [for (final action in actions) PopupMenuItem(value: action, child: Text(action.label))],
);
}
}
extension _TileActionLabel on _TileAction {
String get label {
return switch (this) {
_TileAction.manage => t.profiles.manage,
_TileAction.delete => t.profiles.delete,
_TileAction.signOut => t.profiles.signOut,
};
}
}
enum _TileAction { manage, delete, signOut }
+84 -84
View File
@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../media/media_backend.dart';
import '../../profiles/profile.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../profile/borrow_connection_screen.dart';
import 'add_jellyfin_screen.dart';
import 'add_plex_account_screen.dart';
@@ -46,64 +47,57 @@ class AddConnectionScreen extends StatelessWidget {
builder: (_) => AddJellyfinScreen(targetProfile: targetProfile),
),
];
return Scaffold(
body: CustomScrollView(
slivers: [
ExcludeFocus(
child: CustomAppBar(
title: Text(
scoped
? t.addServer.addConnectionTitleScoped(name: targetProfile!.displayName)
: t.addServer.addConnectionTitle,
),
pinned: true,
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Text(
scoped ? t.addServer.addConnectionIntroScoped : t.addServer.addConnectionIntroGlobal,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
for (var i = 0; i < options.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_BackendCard(
leading: BackendBadge(backend: options[i].backend, size: 28),
title: options[i].title,
subtitle: options[i].subtitle,
onTap: () async {
final added = await Navigator.push<bool>(context, MaterialPageRoute(builder: options[i].builder));
if (added == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
],
if (scoped) ...[
const SizedBox(height: 12),
_BackendCard(
leading: const AppIcon(Symbols.share_rounded, fill: 1, size: 28),
title: t.addServer.borrowFromAnotherProfile,
subtitle: t.addServer.borrowFromAnotherProfileSubtitle,
onTap: () async {
final added = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: targetProfile!)),
);
if (added == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
],
]),
),
),
],
return FocusedScrollScaffold(
title: Text(
scoped
? t.addServer.addConnectionTitleScoped(name: targetProfile!.displayName)
: t.addServer.addConnectionTitle,
),
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Text(
scoped ? t.addServer.addConnectionIntroScoped : t.addServer.addConnectionIntroGlobal,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
for (var i = 0; i < options.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
_BackendCard(
leading: BackendBadge(backend: options[i].backend, size: 28),
title: options[i].title,
subtitle: options[i].subtitle,
onTap: () async {
final added = await Navigator.push<bool>(context, MaterialPageRoute(builder: options[i].builder));
if (added == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
],
if (scoped) ...[
const SizedBox(height: 12),
_BackendCard(
leading: const AppIcon(Symbols.share_rounded, fill: 1, size: 28),
title: t.addServer.borrowFromAnotherProfile,
subtitle: t.addServer.borrowFromAnotherProfileSubtitle,
onTap: () async {
final added = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: targetProfile!)),
);
if (added == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
],
]),
),
),
],
);
}
}
@@ -128,35 +122,41 @@ class _BackendCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
return FocusableWrapper(
disableScale: true,
borderRadius: 12,
descendantsAreFocusable: false,
onSelect: onTap,
child: Material(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
leading,
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
leading,
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
],
],
),
),
),
const AppIcon(Symbols.chevron_right_rounded, fill: 1),
],
const AppIcon(Symbols.chevron_right_rounded, fill: 1),
],
),
),
),
),
+68 -27
View File
@@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
import '../../connection/connection.dart';
import '../../exceptions/media_server_exceptions.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
@@ -21,7 +22,7 @@ import '../../services/jellyfin_auth_service.dart';
import '../../services/storage_service.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_detector.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../profile/profile_switch_screen.dart';
import 'async_form_state_mixin.dart';
import 'connection_persistence.dart';
@@ -70,10 +71,16 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
late final _urlController = createTextEditingController();
late final _usernameController = createTextEditingController();
late final _passwordController = createTextEditingController();
final _urlFocus = FocusNode(debugLabel: 'AddJellyfin:Url');
final _findServerFocus = FocusNode(debugLabel: 'AddJellyfin:FindServer');
final _usernameFocus = FocusNode(debugLabel: 'AddJellyfin:Username');
// Owned so the username field can advance focus on Enter; mobile keyboards
// act on `textInputAction: next` automatically but TV remotes / hardware
// keyboards need the explicit `onFieldSubmitted` handler below.
final _passwordFocus = FocusNode();
final _passwordFocus = FocusNode(debugLabel: 'AddJellyfin:Password');
final _signInFocus = FocusNode(debugLabel: 'AddJellyfin:SignIn');
final _quickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:QuickConnect');
final _cancelQuickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:CancelQuickConnect');
final _formKey = GlobalKey<FormState>();
JellyfinServerInfo? _serverInfo;
@@ -88,7 +95,13 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
// setState after the widget is gone.
_qcCancelled = true;
_qcAttemptId++;
_urlFocus.dispose();
_findServerFocus.dispose();
_usernameFocus.dispose();
_passwordFocus.dispose();
_signInFocus.dispose();
_quickConnectFocus.dispose();
_cancelQuickConnectFocus.dispose();
super.dispose();
}
@@ -308,21 +321,19 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: CustomScrollView(
slivers: [
ExcludeFocus(child: CustomAppBar(title: Text(t.addServer.addJellyfinTitle), pinned: true)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
sliver: SliverToBoxAdapter(
child: Form(
key: _formKey,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildBodyChildren(theme)),
),
return FocusedScrollScaffold(
title: Text(t.addServer.addJellyfinTitle),
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
sliver: SliverToBoxAdapter(
child: Form(
key: _formKey,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildBodyChildren(theme)),
),
),
],
),
),
],
);
}
@@ -341,11 +352,13 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
const SizedBox(height: 16),
FocusableTextFormField(
controller: _urlController,
focusNode: _urlFocus,
autofocus: true,
keyboardType: TextInputType.url,
autocorrect: false,
enableSuggestions: false,
enabled: !busy,
onNavigateDown: _serverInfo == null ? () => _findServerFocus.requestFocus() : null,
textInputAction: TextInputAction.go,
onFieldSubmitted: busy ? null : (_) => _probe(),
decoration: InputDecoration(
@@ -356,10 +369,16 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
),
if (_serverInfo == null) ...[
const SizedBox(height: 16),
FilledButton.icon(
FocusableButton(
focusNode: _findServerFocus,
useBackgroundFocus: true,
onPressed: busy ? null : _probe,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1),
label: Text(t.addServer.findServer),
onNavigateUp: () => _urlFocus.requestFocus(),
child: FilledButton.icon(
onPressed: busy ? null : _probe,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1),
label: Text(t.addServer.findServer),
),
),
] else ...[
const SizedBox(height: 16),
@@ -367,9 +386,11 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
const SizedBox(height: 16),
FocusableTextFormField(
controller: _usernameController,
focusNode: _usernameFocus,
autocorrect: false,
enableSuggestions: false,
enabled: !busy,
onNavigateDown: () => _passwordFocus.requestFocus(),
textInputAction: TextInputAction.next,
onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(),
decoration: InputDecoration(
@@ -384,6 +405,8 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
focusNode: _passwordFocus,
obscureText: true,
enabled: !busy,
onNavigateUp: () => _usernameFocus.requestFocus(),
onNavigateDown: () => _signInFocus.requestFocus(),
textInputAction: TextInputAction.done,
onFieldSubmitted: busy ? null : (_) => _signIn(),
decoration: InputDecoration(
@@ -394,17 +417,30 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
// require a value.
),
const SizedBox(height: 16),
FilledButton.icon(
FocusableButton(
focusNode: _signInFocus,
useBackgroundFocus: true,
onPressed: busy ? null : _signIn,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
label: Text(t.addServer.signIn),
onNavigateUp: () => _passwordFocus.requestFocus(),
onNavigateDown: _quickConnectEnabled ? () => _quickConnectFocus.requestFocus() : null,
child: FilledButton.icon(
onPressed: busy ? null : _signIn,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
label: Text(t.addServer.signIn),
),
),
if (_quickConnectEnabled) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
FocusableButton(
focusNode: _quickConnectFocus,
useBackgroundFocus: true,
onPressed: busy ? null : _startQuickConnect,
icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1),
label: Text(t.auth.useQuickConnect),
onNavigateUp: () => _signInFocus.requestFocus(),
child: OutlinedButton.icon(
onPressed: busy ? null : _startQuickConnect,
icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1),
label: Text(t.auth.useQuickConnect),
),
),
],
],
@@ -492,10 +528,15 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
],
),
const SizedBox(height: 20),
OutlinedButton.icon(
FocusableButton(
focusNode: _cancelQuickConnectFocus,
useBackgroundFocus: true,
onPressed: _cancelQuickConnect,
icon: const AppIcon(Symbols.close_rounded, fill: 1),
label: Text(t.auth.quickConnectCancel),
child: OutlinedButton.icon(
onPressed: _cancelQuickConnect,
icon: const AppIcon(Symbols.close_rounded, fill: 1),
label: Text(t.auth.quickConnectCancel),
),
),
];
}
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../../connection/connection.dart';
import '../../connection/connection_registry.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart';
@@ -17,7 +18,7 @@ import '../../services/plex_auth_service.dart';
import '../../utils/app_logger.dart';
import '../../media/media_backend.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../auth/plex_pin_auth_flow.dart';
import '../profile/borrow_connection_screen.dart';
import 'async_form_state_mixin.dart';
@@ -158,52 +159,58 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: CustomScrollView(
slivers: [
ExcludeFocus(child: CustomAppBar(title: Text(t.addServer.addPlexTitle), pinned: true)),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(t.addServer.plexAuthIntro, style: theme.textTheme.bodyMedium),
const SizedBox(height: 24),
PlexPinAuthFlow(
onTokenReceived: _onTokenReceived,
initialButtonsBuilder: (context, browser, qr, busy) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
return FocusedScrollScaffold(
title: Text(t.addServer.addPlexTitle),
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(t.addServer.plexAuthIntro, style: theme.textTheme.bodyMedium),
const SizedBox(height: 24),
PlexPinAuthFlow(
onTokenReceived: _onTokenReceived,
initialButtonsBuilder: (context, browser, qr, busy) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FocusableButton(
useBackgroundFocus: true,
onPressed: busy || this.busy ? null : browser,
child: FilledButton.icon(
onPressed: busy || this.busy ? null : browser,
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
label: Text(t.auth.signInWithPlex),
),
const SizedBox(height: 12),
OutlinedButton.icon(
),
const SizedBox(height: 12),
FocusableButton(
useBackgroundFocus: true,
onPressed: busy || this.busy ? null : qr,
child: OutlinedButton.icon(
onPressed: busy || this.busy ? null : qr,
icon: const AppIcon(Symbols.qr_code_rounded, fill: 1),
label: Text(t.auth.showQRCode),
),
],
),
),
],
),
),
if (errorText != null) ...[
const SizedBox(height: 16),
Text(
errorText!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
if (errorText != null) ...[
const SizedBox(height: 16),
Text(
errorText!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
],
],
),
],
),
),
],
),
),
],
);
}
}
@@ -4,6 +4,7 @@ import '../../i18n/strings.g.dart';
import '../../models/hotkey_model.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../utils/snackbar_helper.dart';
import '../../focus/focusable_button.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import 'hotkey_recorder_widget.dart';
@@ -21,18 +22,21 @@ class KeyboardShortcutsScreen extends StatelessWidget {
final actions = hotkeys.keys.toList();
return FocusedScrollScaffold(
title: Text(t.settings.keyboardShortcuts),
actions: [
TextButton(
onPressed: () async {
await keyboardService.resetToDefaults();
if (context.mounted) showSuccessSnackBar(context, t.settings.shortcutsReset);
},
child: Text(t.common.reset),
),
],
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Align(
alignment: Alignment.centerRight,
child: FocusableButton(
onPressed: () => _resetShortcuts(context),
child: TextButton(onPressed: () => _resetShortcuts(context), child: Text(t.common.reset)),
),
),
),
),
SliverPadding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final action = actions[index];
@@ -66,6 +70,11 @@ class KeyboardShortcutsScreen extends StatelessWidget {
);
}
Future<void> _resetShortcuts(BuildContext context) async {
await keyboardService.resetToDefaults();
if (context.mounted) showSuccessSnackBar(context, t.settings.shortcutsReset);
}
void _editHotkey(BuildContext screenContext, String action, HotKey currentHotkey) {
showDialog(
context: screenContext,
+3 -1
View File
@@ -13,6 +13,7 @@ import '../../utils/snackbar_helper.dart';
import '../../mixins/settings_effect_mixin.dart';
import '../../services/settings_service.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/focusable_popup_menu_button.dart';
import '../../widgets/settings_builder.dart';
class MpvConfigScreen extends StatefulWidget {
@@ -209,7 +210,8 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
(preset) => ListTile(
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
title: Text(preset.name),
trailing: PopupMenuButton<String>(
trailing: FocusablePopupMenuButton<String>(
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
onSelected: (value) {
if (value == 'load') {
_loadPreset(preset);
+21 -3
View File
@@ -30,7 +30,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton(
FocusableButton(
autofocus: true,
onPressed: () {
final playerToDispose = player;
@@ -42,10 +42,28 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
});
unawaited(_initializePlayer());
},
child: Text(t.common.retry),
child: FilledButton(
onPressed: () {
final playerToDispose = player;
player = null;
if (playerToDispose != null) unawaited(playerToDispose.dispose());
_setPlayerState(() {
_playerInitializationError = null;
_isPlayerInitialized = false;
});
unawaited(_initializePlayer());
},
child: Text(t.common.retry),
),
),
const SizedBox(width: 12),
OutlinedButton(onPressed: () => unawaited(_handleBackButton()), child: Text(t.common.back)),
FocusableButton(
onPressed: () => unawaited(_handleBackButton()),
child: OutlinedButton(
onPressed: () => unawaited(_handleBackButton()),
child: Text(t.common.back),
),
),
],
),
],
+1
View File
@@ -70,6 +70,7 @@ import 'video_player/widgets/player_prompt_overlays.dart';
import '../widgets/overlay_sheet.dart';
import '../widgets/video_controls/video_controls.dart';
import '../widgets/video_controls/widgets/player_toast_indicator.dart';
import '../focus/focusable_button.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
+31 -3
View File
@@ -214,6 +214,8 @@ Future<String?> showMultilineTextInputDialog(
/// the save button.
mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T>, ControllerDisposerMixin<T> {
late final TextEditingController _controller;
final _fieldFocusNode = FocusNode();
final _cancelFocusNode = FocusNode();
final _saveFocusNode = FocusNode();
String? get initialValue;
@@ -226,6 +228,8 @@ mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T>, Controll
@override
void dispose() {
_fieldFocusNode.dispose();
_cancelFocusNode.dispose();
_saveFocusNode.dispose();
super.dispose();
}
@@ -255,19 +259,29 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
width: 400,
child: FocusableTextField(
controller: _controller,
focusNode: _fieldFocusNode,
autofocus: true,
decoration: InputDecoration(labelText: widget.labelText),
keyboardType: TextInputType.multiline,
maxLines: 8,
minLines: 3,
onNavigateDown: _saveFocusNode.requestFocus,
),
),
actions: [
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
DialogActionButton(
focusNode: _cancelFocusNode,
onPressed: () => Navigator.pop(context),
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateRight: _saveFocusNode.requestFocus,
label: t.common.cancel,
),
DialogActionButton(
onPressed: () => Navigator.pop(context, _controller.text),
label: t.common.save,
focusNode: _saveFocusNode,
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateLeft: _cancelFocusNode.requestFocus,
),
],
);
@@ -317,16 +331,30 @@ class _TextInputDialogState extends State<_TextInputDialog>
title: Text(widget.title),
content: FocusableTextField(
controller: _controller,
focusNode: _fieldFocusNode,
autofocus: true,
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText),
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
textInputAction: TextInputAction.done,
onNavigateDown: _saveFocusNode.requestFocus,
onSubmitted: (_) => _saveFocusNode.requestFocus(),
),
actions: [
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel),
DialogActionButton(onPressed: _submit, label: widget.confirmText ?? t.common.save, focusNode: _saveFocusNode),
DialogActionButton(
focusNode: _cancelFocusNode,
onPressed: () => Navigator.pop(context),
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateRight: _saveFocusNode.requestFocus,
label: t.common.cancel,
),
DialogActionButton(
onPressed: _submit,
label: widget.confirmText ?? t.common.save,
focusNode: _saveFocusNode,
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateLeft: _cancelFocusNode.requestFocus,
),
],
);
}
@@ -301,6 +301,9 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
Future<void> _renameRoom(RecentRoom room) async {
final controller = TextEditingController(text: room.name ?? '');
final fieldFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameField');
final cancelFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameCancel');
final saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRenameSave');
String? name;
try {
name = await showDialog<String>(
@@ -309,18 +312,36 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
title: Text(t.watchTogether.renameRoom),
content: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
autofocus: true,
decoration: InputDecoration(hintText: room.code),
onNavigateDown: saveFocusNode.requestFocus,
onSubmitted: (value) => Navigator.pop(context, value),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
FilledButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(t.common.save)),
DialogActionButton(
focusNode: cancelFocusNode,
onPressed: () => Navigator.pop(context),
onNavigateUp: fieldFocusNode.requestFocus,
onNavigateRight: saveFocusNode.requestFocus,
label: t.common.cancel,
),
DialogActionButton(
focusNode: saveFocusNode,
onPressed: () => Navigator.pop(context, controller.text),
onNavigateUp: fieldFocusNode.requestFocus,
onNavigateLeft: cancelFocusNode.requestFocus,
isPrimary: true,
label: t.common.save,
),
],
),
);
} finally {
controller.dispose();
fieldFocusNode.dispose();
cancelFocusNode.dispose();
saveFocusNode.dispose();
}
if (name == null || !mounted) return;
@@ -18,6 +18,17 @@ class JoinSessionDialog extends StatefulWidget {
class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDisposerMixin {
final _formKey = GlobalKey<FormState>();
late final _sessionIdController = createTextEditingController();
final _closeFocusNode = FocusNode(debugLabel: 'JoinSessionClose');
final _sessionIdFocusNode = FocusNode(debugLabel: 'JoinSessionCode');
final _joinFocusNode = FocusNode(debugLabel: 'JoinSessionSubmit');
@override
void dispose() {
_closeFocusNode.dispose();
_sessionIdFocusNode.dispose();
_joinFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -40,10 +51,13 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
const SizedBox(width: 12),
Expanded(child: Text(t.watchTogether.joinWatchSession, style: theme.textTheme.titleLarge)),
FocusableWrapper(
focusNode: _closeFocusNode,
useBackgroundFocus: true,
disableScale: true,
borderRadius: 20,
descendantsAreFocusable: false,
onSelect: () => Navigator.of(context).pop(),
onNavigateDown: _sessionIdFocusNode.requestFocus,
child: IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)),
),
],
@@ -53,6 +67,7 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
FocusableTextFormField(
controller: _sessionIdController,
focusNode: _sessionIdFocusNode,
decoration: InputDecoration(
labelText: t.watchTogether.sessionCode,
hintText: t.watchTogether.enterCodeHint,
@@ -79,6 +94,8 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
}
return null;
},
onNavigateUp: _closeFocusNode.requestFocus,
onNavigateDown: _joinFocusNode.requestFocus,
onFieldSubmitted: (_) => _join(),
autofocus: true,
),
@@ -93,7 +110,9 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
const SizedBox(height: 24),
FocusableButton(
focusNode: _joinFocusNode,
onPressed: _join,
onNavigateUp: _sessionIdFocusNode.requestFocus,
child: FilledButton.icon(
onPressed: _join,
icon: const Icon(Symbols.group_add),
+10 -6
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../i18n/strings.g.dart';
import '../providers/multi_server_provider.dart';
import '../screens/settings/add_connection_screen.dart';
import '../focus/focusable_button.dart';
import 'app_icon.dart';
/// Top-of-app banner shown when one or more servers' tokens have been
@@ -53,13 +54,16 @@ class AuthErrorBanner extends StatelessWidget {
),
),
const SizedBox(width: 8),
FilledButton.tonal(
style: FilledButton.styleFrom(
backgroundColor: scheme.onErrorContainer,
foregroundColor: scheme.errorContainer,
),
FocusableButton(
onPressed: () => _openReauth(context),
child: Text(t.connections.signInAgain),
child: FilledButton.tonal(
style: FilledButton.styleFrom(
backgroundColor: scheme.onErrorContainer,
foregroundColor: scheme.errorContainer,
),
onPressed: () => _openReauth(context),
child: Text(t.connections.signInAgain),
),
),
],
),
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../connection/connection_registry.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../mixins/mounted_set_state_mixin.dart';
@@ -27,6 +29,9 @@ class DiscoveryView extends StatefulWidget {
class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMixin, MountedSetStateMixin {
late final _hostAddressController = createTextEditingController();
final _manualToggleFocusNode = FocusNode(debugLabel: 'CompanionManualToggle');
final _hostAddressFocusNode = FocusNode(debugLabel: 'CompanionHostAddress');
final _connectFocusNode = FocusNode(debugLabel: 'CompanionConnect');
final _formKey = GlobalKey<FormState>();
bool _isConnecting = false;
String? _errorMessage;
@@ -111,6 +116,9 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
_discoverySubscription?.cancel();
_searchTimeout?.cancel();
_provider.stopDiscovery();
_manualToggleFocusNode.dispose();
_hostAddressFocusNode.dispose();
_connectFocusNode.dispose();
super.dispose();
}
@@ -284,20 +292,35 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _showManualEntry = !_showManualEntry),
child: Row(
children: [
Icon(
_showManualEntry ? Icons.expand_less : Icons.expand_more,
color: Theme.of(context).colorScheme.primary,
FocusableWrapper(
focusNode: _manualToggleFocusNode,
useBackgroundFocus: true,
disableScale: true,
borderRadius: 8,
onSelect: () => setState(() => _showManualEntry = !_showManualEntry),
onNavigateDown: _showManualEntry ? _hostAddressFocusNode.requestFocus : null,
child: InkWell(
canRequestFocus: false,
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () => setState(() => _showManualEntry = !_showManualEntry),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
_showManualEntry ? Icons.expand_less : Icons.expand_more,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
t.companionRemote.pairing.manualConnection,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary),
),
],
),
const SizedBox(width: 8),
Text(
t.companionRemote.pairing.manualConnection,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary),
),
],
),
),
),
if (_showManualEntry) ...[
@@ -309,6 +332,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
children: [
FocusableTextFormField(
controller: _hostAddressController,
focusNode: _hostAddressFocusNode,
decoration: InputDecoration(
labelText: t.companionRemote.session.hostAddress,
hintText: t.companionRemote.pairing.hostAddressHint,
@@ -325,17 +349,29 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
return null;
},
enabled: !_isConnecting,
onNavigateUp: _manualToggleFocusNode.requestFocus,
onNavigateDown: _connectFocusNode.requestFocus,
),
const SizedBox(height: 16),
FilledButton.icon(
FocusableButton(
focusNode: _connectFocusNode,
onNavigateUp: _hostAddressFocusNode.requestFocus,
onPressed: _isConnecting
? null
: () {
if (!_formKey.currentState!.validate()) return;
_connect(() => _provider.connectToManualHost(_hostAddressController.text.trim()));
},
icon: _isConnecting ? const LoadingIndicatorBox(size: 16) : const Icon(Icons.link),
label: Text(_isConnecting ? t.companionRemote.pairing.connecting : t.common.connect),
child: FilledButton.icon(
onPressed: _isConnecting
? null
: () {
if (!_formKey.currentState!.validate()) return;
_connect(() => _provider.connectToManualHost(_hostAddressController.text.trim()));
},
icon: _isConnecting ? const LoadingIndicatorBox(size: 16) : const Icon(Icons.link),
label: Text(_isConnecting ? t.companionRemote.pairing.connecting : t.common.connect),
),
),
],
),
+7 -3
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../models/trackers/device_code.dart';
import '../utils/snackbar_helper.dart';
@@ -63,10 +64,13 @@ class DeviceCodeDialog extends StatelessWidget {
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(t.trackers.deviceCode.openToActivate(service: serviceName)),
child: FocusableButton(
onPressed: _open,
child: FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(t.trackers.deviceCode.openToActivate(service: serviceName)),
onPressed: _open,
),
),
),
const SizedBox(height: 16),
+13
View File
@@ -13,6 +13,10 @@ class DialogActionButton extends StatelessWidget {
final String label;
final FocusNode? focusNode;
final bool isPrimary;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
const DialogActionButton({
super.key,
@@ -20,6 +24,10 @@ class DialogActionButton extends StatelessWidget {
required this.label,
this.focusNode,
this.isPrimary = false,
this.onNavigateUp,
this.onNavigateDown,
this.onNavigateLeft,
this.onNavigateRight,
});
@override
@@ -27,6 +35,11 @@ class DialogActionButton extends StatelessWidget {
return FocusableButton(
focusNode: focusNode,
onPressed: onPressed,
useBackgroundFocus: isPrimary,
onNavigateUp: onNavigateUp,
onNavigateDown: onNavigateDown,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
child: isPrimary
? FilledButton(onPressed: onPressed, child: Text(label))
: TextButton(onPressed: onPressed, child: Text(label)),
+1
View File
@@ -129,6 +129,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
key: Key(widget.episode.id),
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
onTap: widget.onTap,
canRequestFocus: false,
onTapDown: storeTapPosition,
onLongPress: showContextMenuFromTap,
onSecondaryTapDown: storeTapPosition,
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../focus/focusable_wrapper.dart';
/// A [PopupMenuButton] that can be focused and opened with D-pad select.
class FocusablePopupMenuButton<T> extends StatefulWidget {
final Widget? icon;
final String? tooltip;
final PopupMenuItemBuilder<T> itemBuilder;
final PopupMenuItemSelected<T>? onSelected;
final GlobalKey<PopupMenuButtonState<T>>? menuKey;
final FocusNode? focusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
final String? semanticLabel;
final double borderRadius;
final bool useBackgroundFocus;
final bool enableLongPress;
const FocusablePopupMenuButton({
super.key,
this.icon,
this.tooltip,
required this.itemBuilder,
this.onSelected,
this.menuKey,
this.focusNode,
this.onNavigateUp,
this.onNavigateDown,
this.onNavigateLeft,
this.onNavigateRight,
this.semanticLabel,
this.borderRadius = 100,
this.useBackgroundFocus = true,
this.enableLongPress = true,
});
@override
State<FocusablePopupMenuButton<T>> createState() => _FocusablePopupMenuButtonState<T>();
}
class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T>> {
final _ownedMenuKey = GlobalKey<PopupMenuButtonState<T>>();
GlobalKey<PopupMenuButtonState<T>> get _menuKey => widget.menuKey ?? _ownedMenuKey;
void _showMenu() => _menuKey.currentState?.showButtonMenu();
@override
Widget build(BuildContext context) {
return FocusableWrapper(
focusNode: widget.focusNode,
disableScale: true,
borderRadius: widget.borderRadius,
useBackgroundFocus: widget.useBackgroundFocus,
descendantsAreFocusable: false,
semanticLabel: widget.semanticLabel ?? widget.tooltip,
enableLongPress: widget.enableLongPress,
onNavigateUp: widget.onNavigateUp,
onNavigateDown: widget.onNavigateDown,
onNavigateLeft: widget.onNavigateLeft,
onNavigateRight: widget.onNavigateRight,
onSelect: _showMenu,
onLongPress: widget.enableLongPress ? _showMenu : null,
child: PopupMenuButton<T>(
key: _menuKey,
icon: widget.icon,
tooltip: widget.tooltip,
requestFocus: true,
onSelected: widget.onSelected,
itemBuilder: widget.itemBuilder,
),
);
}
}
+3 -2
View File
@@ -65,10 +65,11 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
void _requestInitialFocus() {
if (_focusRequested || !mounted || !InputModeTracker.isKeyboardMode(context)) return;
_focusRequested = true;
_scopeNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
primaryFocus?.nextFocus();
if (_scopeNode.focusedChild != null) return;
_scopeNode.requestFocus();
_scopeNode.nextFocus();
});
}
+15 -2
View File
@@ -1553,8 +1553,17 @@ class _CollectionSelectionDialog extends StatefulWidget {
class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> with ControllerDisposerMixin {
late final _filterController = createTextEditingController();
final _filterFocusNode = FocusNode(debugLabel: 'CollectionFilter');
final _firstCollectionFocusNode = FocusNode(debugLabel: 'CollectionFirstItem');
late List<MediaItem> _filteredCollections = widget.collections;
@override
void dispose() {
_filterFocusNode.dispose();
_firstCollectionFocusNode.dispose();
super.dispose();
}
void _onFilterChanged(String query) {
final lower = query.toLowerCase();
setState(() {
@@ -1576,7 +1585,9 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
if (widget.collections.length >= 10) ...[
FocusableTextField(
controller: _filterController,
focusNode: _filterFocusNode,
autofocus: true,
onNavigateDown: _firstCollectionFocusNode.requestFocus,
decoration: pillInputDecoration(
context,
hintText: t.collections.searchCollections,
@@ -1592,7 +1603,9 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
itemCount: _filteredCollections.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return ListTile(
return FocusableListTile(
focusNode: _firstCollectionFocusNode,
autofocus: widget.collections.length < 10,
leading: const AppIcon(Symbols.add_rounded, fill: 1),
title: Text(t.common.createNew),
onTap: () => Navigator.pop(context, '_create_new'),
@@ -1600,7 +1613,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
}
final collection = _filteredCollections[index - 1];
return ListTile(
return FocusableListTile(
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
title: Text(collection.title ?? ''),
subtitle: collection.childCount != null
+7 -3
View File
@@ -4,6 +4,7 @@ import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../i18n/strings.g.dart';
import '../focus/focusable_button.dart';
import '../services/trackers/oauth_proxy_client.dart';
import '../utils/snackbar_helper.dart';
import 'dialog_action_button.dart';
@@ -72,10 +73,13 @@ class OAuthProxyDialog extends StatelessWidget {
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(t.trackers.oauthProxy.openToSignIn(service: serviceName)),
child: FocusableButton(
onPressed: _open,
child: FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(t.trackers.oauthProxy.openToSignIn(service: serviceName)),
onPressed: _open,
),
),
),
const SizedBox(height: 16),
+19 -4
View File
@@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../widgets/app_icon.dart';
import '../widgets/overlay_sheet.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_button.dart';
import '../focus/input_mode_tracker.dart';
import '../i18n/strings.g.dart';
import '../utils/formatters.dart';
@@ -111,24 +112,38 @@ class _RatingBottomSheetState extends State<RatingBottomSheet> {
children: [
if (widget.currentRating > 0)
Expanded(
child: OutlinedButton(
child: FocusableButton(
onPressed: () {
widget.onClear();
OverlaySheetController.closeAdaptive(context);
},
child: Text(t.common.clear),
child: OutlinedButton(
onPressed: () {
widget.onClear();
OverlaySheetController.closeAdaptive(context);
},
child: Text(t.common.clear),
),
),
),
if (widget.currentRating > 0) const SizedBox(width: 12),
Expanded(
child: FilledButton(
child: FocusableButton(
onPressed: _selectedRating > 0
? () {
widget.onRate(_selectedRating);
OverlaySheetController.closeAdaptive(context);
}
: null,
child: Text(t.mediaMenu.rate),
child: FilledButton(
onPressed: _selectedRating > 0
? () {
widget.onRate(_selectedRating);
OverlaySheetController.closeAdaptive(context);
}
: null,
child: Text(t.mediaMenu.rate),
),
),
),
],
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/focusable_button.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../i18n/strings.g.dart';
import '../../../utils/formatters.dart';
@@ -33,26 +34,37 @@ class SleepTimerActiveStatus extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
icon: const AppIcon(Symbols.add_rounded, fill: 1),
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: Theme.of(context).colorScheme.outline),
),
FocusableButton(
onPressed: () {
sleepTimer.extendTimer(const Duration(minutes: 15));
},
child: OutlinedButton.icon(
icon: const AppIcon(Symbols.add_rounded, fill: 1),
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: Theme.of(context).colorScheme.outline),
),
onPressed: () {
sleepTimer.extendTimer(const Duration(minutes: 15));
},
),
),
const SizedBox(width: 12),
FilledButton.icon(
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
label: Text(t.common.cancel),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
FocusableButton(
onPressed: () {
sleepTimer.cancelTimer();
onCancel?.call();
},
child: FilledButton.icon(
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
label: Text(t.common.cancel),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () {
sleepTimer.cancelTimer();
onCancel?.call();
},
),
),
],
),
@@ -6,6 +6,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/focusable_button.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../mpv/mpv.dart';
import '../../../i18n/strings.g.dart';
@@ -163,19 +164,25 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
double size = 48,
double iconSize = 28,
}) {
return GestureDetector(
onTap: onTap,
onLongPressStart: (_) => onLongPressStart(),
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: _stopLongPress,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(8)),
return FocusableWrapper(
onSelect: onTap,
borderRadius: 18,
autoScroll: false,
useBackgroundFocus: true,
child: GestureDetector(
onTap: onTap,
onLongPressStart: (_) => onLongPressStart(),
onLongPressEnd: (_) => _stopLongPress(),
onLongPressCancel: _stopLongPress,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Icon(icon, color: tokens(context).text, size: iconSize),
),
child: Icon(icon, color: tokens(context).text, size: iconSize),
),
);
}
@@ -358,11 +365,14 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
),
const SizedBox(height: 24),
// Reset button
ElevatedButton.icon(
FocusableButton(
onPressed: _currentOffset != 0 ? _resetOffset : null,
icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1),
label: Text(t.videoControls.resetToZero),
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
child: ElevatedButton.icon(
onPressed: _currentOffset != 0 ? _resetOffset : null,
icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1),
label: Text(t.videoControls.resetToZero),
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
),
],
),
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/profile/add_local_profile_screen.dart';
import 'package:plezy/utils/platform_detector.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
TvDetectionService.debugSetAppleTVOverride(true);
LocaleSettings.setLocaleSync(AppLocale.en);
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('D-pad leaves profile name input and reaches actions', (tester) async {
await tester.pumpWidget(
TranslationProvider(
child: const InputModeTracker(child: MaterialApp(home: AddLocalProfileScreen())),
),
);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Name');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Continue');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel');
});
testWidgets('remote back pops the new profile page', (tester) async {
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () =>
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddLocalProfileScreen())),
child: const Text('Open new profile'),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open new profile'));
await tester.pumpAndSettle();
expect(find.text(t.profiles.newProfile), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('Open new profile'), findsOneWidget);
expect(find.text(t.profiles.newProfile), findsNothing);
});
}
@@ -0,0 +1,114 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_connection.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/screens/profile/profile_detail_screen.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
TvDetectionService.debugSetAppleTVOverride(null);
LocaleSettings.setLocaleSync(AppLocale.en);
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('remote back pops the manage profile page', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profile = Profile(
id: 'local-owner',
kind: ProfileKind.local,
displayName: 'Owner',
createdAt: DateTime(2026, 1, 1),
);
final profiles = ProfileRegistry(db);
final connections = _FakeConnectionRegistry(db);
final profileConnections = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
addTearDown(() async {
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<ProfileRegistry>.value(value: profiles),
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<PlexHomeService>.value(value: plexHome),
],
child: InputModeTracker(
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => ProfileDetailScreen(profile: profile)));
},
child: const Text('Open profile'),
),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open profile'));
await tester.pumpAndSettle();
expect(find.text(t.profiles.connectionsLabel), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('Open profile'), findsOneWidget);
expect(find.text(t.profiles.connectionsLabel), findsNothing);
});
}
class _FakeConnectionRegistry extends ConnectionRegistry {
_FakeConnectionRegistry(super.db);
@override
Future<List<Connection>> list() async => const [];
}
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
_FakeProfileConnectionRegistry(super.db);
@override
Stream<List<ProfileConnection>> watchForProfile(String profileId) => Stream.value(const []);
}
@@ -0,0 +1,121 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_connection.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/screens/profile/profile_switch_screen.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('D-pad can focus profile actions and open the manage menu', (tester) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profile = Profile(
id: 'local-owner',
kind: ProfileKind.local,
displayName: 'Owner',
createdAt: DateTime(2026, 1, 1),
);
final profiles = _FakeProfileRegistry(db, [profile]);
final connections = _FakeConnectionRegistry(db);
final profileConnections = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfile = ActiveProfileProvider(
registry: profiles,
plexHome: plexHome,
connections: connections,
storage: storage,
);
addTearDown(() async {
activeProfile.dispose();
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<ProfileRegistry>.value(value: profiles),
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<PlexHomeService>.value(value: plexHome),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
],
child: const MaterialApp(home: ProfileSwitchScreen()),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Owner'), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ProfileTile:local-owner');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ProfileActions:local-owner');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text(t.profiles.manage), findsOneWidget);
expect(find.text(t.profiles.delete), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
}
class _FakeProfileRegistry extends ProfileRegistry {
final List<Profile> _profiles;
_FakeProfileRegistry(super.db, this._profiles);
@override
Stream<List<Profile>> watchProfiles() => Stream.value(_profiles);
@override
Future<List<Profile>> list() async => _profiles;
}
class _FakeConnectionRegistry extends ConnectionRegistry {
_FakeConnectionRegistry(super.db);
@override
Stream<List<Connection>> watchConnections() => Stream.value(const []);
@override
Future<List<Connection>> list() async => const [];
}
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
_FakeProfileConnectionRegistry(super.db);
@override
Stream<List<ProfileConnection>> watchAll() => Stream.value(const []);
}
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/screens/settings/add_jellyfin_screen.dart';
import 'package:plezy/utils/platform_detector.dart';
Profile _profile(String id) => Profile(
id: id,
@@ -12,6 +14,10 @@ Profile _profile(String id) => Profile(
);
void main() {
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('autofocuses the server URL field', (tester) async {
await tester.pumpWidget(const MaterialApp(home: AddJellyfinScreen()));
await tester.pump();
@@ -21,6 +27,15 @@ void main() {
expect(field.autofocus, isTrue);
});
testWidgets('TV initial focus stays on the server URL field', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await tester.pumpWidget(const InputModeTracker(child: MaterialApp(home: AddJellyfinScreen())));
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
});
group('Jellyfin profile binding decisions', () {
test('creates a local profile only on true first-run with no profiles', () {
expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue);
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/focusable_popup_menu_button.dart';
void main() {
testWidgets('D-pad select opens the popup menu', (tester) async {
final focusNode = FocusNode(debugLabel: 'test_popup_menu');
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: FocusablePopupMenuButton<String>(
focusNode: focusNode,
icon: const Icon(Icons.more_vert),
itemBuilder: (_) => const [PopupMenuItem(value: 'one', child: Text('One'))],
),
),
),
),
);
expect(find.text('One'), findsNothing);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.text('One'), findsOneWidget);
});
}