diff --git a/lib/screens/auth/plex_pin_auth_flow.dart b/lib/screens/auth/plex_pin_auth_flow.dart index 8c046a5a..2680097c 100644 --- a/lib/screens/auth/plex_pin_auth_flow.dart +++ b/lib/screens/auth/plex_pin_auth_flow.dart @@ -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 { 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 { ), ), 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 { 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), diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 15498e8b..3678e00e 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -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 { // 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 { ), ], ] 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 { ), ) 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) ...[ diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index 6391c7c5..cf9ab6f8 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -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 { 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, ), diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index fa1e84ae..4174f9cf 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -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 { 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( diff --git a/lib/screens/libraries/state_messages.dart b/lib/screens/libraries/state_messages.dart index 43a3cff5..1fa975fb 100644 --- a/lib/screens/libraries/state_messages.dart +++ b/lib/screens/libraries/state_messages.dart @@ -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!), + ), ), ], ], diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index ff40c828..32fd395f 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -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 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), + ), ), ], ), diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index fefbed21..68d3196d 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -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), ), ], diff --git a/lib/screens/profile/add_local_profile_screen.dart b/lib/screens/profile/add_local_profile_screen.dart index c613e2ea..d05d2a89 100644 --- a/lib/screens/profile/add_local_profile_screen.dart +++ b/lib/screens/profile/add_local_profile_screen.dart @@ -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 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 _setPin() async { final pin = await captureAndConfirmPin( context, @@ -73,49 +87,72 @@ class _AddLocalProfileScreenState extends State 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), + ), + ), + ]), ), - ], - ), + ), + ], ); } } diff --git a/lib/screens/profile/borrow_connection_screen.dart b/lib/screens/profile/borrow_connection_screen.dart index 8c39a2c3..50366edb 100644 --- a/lib/screens/profile/borrow_connection_screen.dart +++ b/lib/screens/profile/borrow_connection_screen.dart @@ -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 { @override Widget build(BuildContext context) { - return Scaffold( - body: FutureBuilder>( - 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>( + 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), + ), + ], + ); + }, ); } diff --git a/lib/screens/profile/pin_status_row.dart b/lib/screens/profile/pin_status_row.dart index 7d2a2bee..866019ff 100644 --- a/lib/screens/profile/pin_status_row.dart +++ b/lib/screens/profile/pin_status_row.dart @@ -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')), + ), ], ); } diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 9a350706..b22ac1bf 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -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 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 with Controll _profile = widget.profile; } + @override + void dispose() { + _nameFocusNode.dispose(); + _saveNameFocusNode.dispose(); + _setPinFocusNode.dispose(); + _addConnectionFocusNode.dispose(); + _deleteProfileFocusNode.dispose(); + super.dispose(); + } + Future _saveName() async { final name = _nameController.text.trim(); if (name.isEmpty || name == _profile.displayName) return; @@ -130,76 +147,96 @@ class _ProfileDetailScreenState extends State 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( + trailing: FocusablePopupMenuButton( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + tooltip: t.profiles.manage, + onSelected: (value) { + if (value == 'default') { + unawaited(pcRegistry.setDefault(profile.id, pc.connectionId)); + } else if (value == '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)), ], ), ), diff --git a/lib/screens/profile/profile_name_field.dart b/lib/screens/profile/profile_name_field.dart index 9d24aa21..54d4182b 100644 --- a/lib/screens/profile/profile_name_field.dart +++ b/lib/screens/profile/profile_name_field.dart @@ -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( diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index fbb06114..45efa11a 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -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 with MountedSetStateMixin { bool _allowPop = false; - final FocusNode _firstSelectableFocusNode = FocusNode(); + final Map _profileFocusNodes = {}; + final Map _profileMenuFocusNodes = {}; + final Map>> _profileMenuKeys = {}; bool _focusRequested = false; bool _switching = false; Stream? _viewStream; @@ -77,7 +80,12 @@ class _ProfileSwitchScreenState extends State 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 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 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 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> _profileMenuKey(Profile profile) { + return _profileMenuKeys.putIfAbsent(profile.id, () => GlobalKey>()); + } + + void _pruneProfileFocusResources(Set 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 _buildSections(ProfilesView view, String? activeId) { return [_profileList(view.profiles, view, activeId, autofocusFirst: true)]; } @@ -177,11 +223,20 @@ class _ProfileSwitchScreenState extends State 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 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 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> 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> 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 } diff --git a/lib/screens/settings/add_connection_screen.dart b/lib/screens/settings/add_connection_screen.dart index cfba70ab..660f819d 100644 --- a/lib/screens/settings/add_connection_screen.dart +++ b/lib/screens/settings/add_connection_screen.dart @@ -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(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( - 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(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( + 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), + ], + ), ), ), ), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 24daff85..62214675 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -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 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(); JellyfinServerInfo? _serverInfo; @@ -88,7 +95,13 @@ class _AddJellyfinScreenState extends State 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 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 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 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 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 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 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 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), + ), ), ]; } diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index 8416c4a8..e6b03ea9 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -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 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, - ), - ], ], - ), + ], ), ), - ], - ), + ), + ], ); } } diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index d131b7ea..66605241 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -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 _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, diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index fed69b5a..f039bcd5 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -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 with SettingsEffectMi (preset) => ListTile( leading: const AppIcon(Symbols.folder_rounded, fill: 1), title: Text(preset.name), - trailing: PopupMenuButton( + trailing: FocusablePopupMenuButton( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), onSelected: (value) { if (value == 'load') { _loadPreset(preset); diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 3f41c721..8b14a222 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -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), + ), + ), ], ), ], diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 9d715d0e..326a5329 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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'; diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 6d440676..7ab583b4 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -214,6 +214,8 @@ Future showMultilineTextInputDialog( /// the save button. mixin _TextInputDialogStateMixin on State, ControllerDisposerMixin { late final TextEditingController _controller; + final _fieldFocusNode = FocusNode(); + final _cancelFocusNode = FocusNode(); final _saveFocusNode = FocusNode(); String? get initialValue; @@ -226,6 +228,8 @@ mixin _TextInputDialogStateMixin on State, 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, + ), ], ); } diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index de84c28a..c506de7f 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -301,6 +301,9 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta Future _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( @@ -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; diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index 33d03b7a..96b0edaa 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -18,6 +18,17 @@ class JoinSessionDialog extends StatefulWidget { class _JoinSessionDialogState extends State with ControllerDisposerMixin { final _formKey = GlobalKey(); 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 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 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 with ControllerDi } return null; }, + onNavigateUp: _closeFocusNode.requestFocus, + onNavigateDown: _joinFocusNode.requestFocus, onFieldSubmitted: (_) => _join(), autofocus: true, ), @@ -93,7 +110,9 @@ class _JoinSessionDialogState extends State 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), diff --git a/lib/widgets/auth_error_banner.dart b/lib/widgets/auth_error_banner.dart index f6f54510..ed3fe2d1 100644 --- a/lib/widgets/auth_error_banner.dart +++ b/lib/widgets/auth_error_banner.dart @@ -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), + ), ), ], ), diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 6f44da14..393ef442 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -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 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(); bool _isConnecting = false; String? _errorMessage; @@ -111,6 +116,9 @@ class _DiscoveryViewState extends State with ControllerDisposerMi _discoverySubscription?.cancel(); _searchTimeout?.cancel(); _provider.stopDiscovery(); + _manualToggleFocusNode.dispose(); + _hostAddressFocusNode.dispose(); + _connectFocusNode.dispose(); super.dispose(); } @@ -284,20 +292,35 @@ class _DiscoveryViewState extends State 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 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 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), + ), ), ], ), diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index fa80a813..dc29c165 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -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), diff --git a/lib/widgets/dialog_action_button.dart b/lib/widgets/dialog_action_button.dart index 7827df10..ec29067a 100644 --- a/lib/widgets/dialog_action_button.dart +++ b/lib/widgets/dialog_action_button.dart @@ -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)), diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index e101fd79..d36fa7b3 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -129,6 +129,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin extends StatefulWidget { + final Widget? icon; + final String? tooltip; + final PopupMenuItemBuilder itemBuilder; + final PopupMenuItemSelected? onSelected; + final GlobalKey>? 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> createState() => _FocusablePopupMenuButtonState(); +} + +class _FocusablePopupMenuButtonState extends State> { + final _ownedMenuKey = GlobalKey>(); + + GlobalKey> 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( + key: _menuKey, + icon: widget.icon, + tooltip: widget.tooltip, + requestFocus: true, + onSelected: widget.onSelected, + itemBuilder: widget.itemBuilder, + ), + ); + } +} diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index acd52403..a0ce63eb 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -65,10 +65,11 @@ class _FocusedScrollScaffoldState extends State { 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(); }); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index bae10327..fec0ef6c 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 _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 diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index fb36d70d..b8511f9c 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -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), diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index 4cbf741f..bec85758 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -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 { 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), + ), ), ), ], diff --git a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart index 3cc1c9dc..eafc0f34 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -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(); + }, + ), ), ], ), diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index db8f8ba2..4dce3304 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -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 { 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 { ), 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)), + ), ), ], ), diff --git a/test/screens/profile/add_local_profile_screen_test.dart b/test/screens/profile/add_local_profile_screen_test.dart new file mode 100644 index 00000000..f7435933 --- /dev/null +++ b/test/screens/profile/add_local_profile_screen_test.dart @@ -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); + }); +} diff --git a/test/screens/profile/profile_detail_screen_test.dart b/test/screens/profile/profile_detail_screen_test.dart new file mode 100644 index 00000000..ec8e6561 --- /dev/null +++ b/test/screens/profile/profile_detail_screen_test.dart @@ -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.value(value: profiles), + Provider.value(value: profileConnections), + Provider.value(value: connections), + Provider.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() async => const []; +} + +class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry { + _FakeProfileConnectionRegistry(super.db); + + @override + Stream> watchForProfile(String profileId) => Stream.value(const []); +} diff --git a/test/screens/profile/profile_switch_screen_test.dart b/test/screens/profile/profile_switch_screen_test.dart new file mode 100644 index 00000000..78c72201 --- /dev/null +++ b/test/screens/profile/profile_switch_screen_test.dart @@ -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.value(value: profiles), + Provider.value(value: profileConnections), + Provider.value(value: connections), + Provider.value(value: plexHome), + ChangeNotifierProvider.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 _profiles; + + _FakeProfileRegistry(super.db, this._profiles); + + @override + Stream> watchProfiles() => Stream.value(_profiles); + + @override + Future> list() async => _profiles; +} + +class _FakeConnectionRegistry extends ConnectionRegistry { + _FakeConnectionRegistry(super.db); + + @override + Stream> watchConnections() => Stream.value(const []); + + @override + Future> list() async => const []; +} + +class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry { + _FakeProfileConnectionRegistry(super.db); + + @override + Stream> watchAll() => Stream.value(const []); +} diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index aa008dd7..d103790c 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -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); diff --git a/test/widgets/focusable_popup_menu_button_test.dart b/test/widgets/focusable_popup_menu_button_test.dart new file mode 100644 index 00000000..71632ee7 --- /dev/null +++ b/test/widgets/focusable_popup_menu_button_test.dart @@ -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( + 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); + }); +}