From e4db04fa62d9b8c046198a211585f669c34e6bbc Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:56:24 +0200 Subject: [PATCH] fix: align UI focus and sheet behavior --- .../dpad_select_long_press_controller.dart | 4 +- lib/focus/focusable_slider.dart | 21 ++ lib/focus/key_event_utils.dart | 23 +- lib/i18n/en.i18n.json | 12 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 48 ++- lib/screens/auth_screen.dart | 6 +- lib/screens/catalog_item_detail_screen.dart | 274 +++++++------- lib/screens/catalog_search_screen.dart | 54 ++- .../mobile_remote_screen.dart | 4 +- lib/screens/discover_screen.dart | 8 +- lib/screens/explore_screen.dart | 47 ++- lib/screens/hub_detail_screen.dart | 16 +- .../libraries/tabs/library_browse_tab.dart | 2 +- lib/screens/livetv/live_tv_actions_mixin.dart | 8 +- lib/screens/livetv/live_tv_screen.dart | 3 + .../livetv/live_tv_show_schedule_screen.dart | 61 ++-- lib/screens/livetv/program_details_sheet.dart | 2 + lib/screens/livetv/record_options_sheet.dart | 5 + .../livetv/reorder_favorites_sheet.dart | 1 + lib/screens/livetv/tabs/guide_tab.dart | 64 +--- lib/screens/livetv/tabs/recordings_tab.dart | 71 ++-- lib/screens/livetv/tabs/whats_on_tab.dart | 57 ++- lib/screens/media_detail_screen.dart | 117 ++---- lib/screens/metadata_edit_screen.dart | 39 +- lib/screens/music/album_detail_screen.dart | 34 +- lib/screens/music/now_playing_screen.dart | 82 ++--- lib/screens/music/queue_sheet.dart | 48 ++- .../playlist/playlist_detail_screen.dart | 29 +- .../profile/borrow_connection_screen.dart | 1 + .../profile/profile_switch_screen.dart | 5 + lib/screens/search_screen.dart | 81 +++-- lib/screens/settings/about_screen.dart | 3 +- .../settings/appearance_settings_screen.dart | 3 +- .../settings/external_player_screen.dart | 24 +- .../settings/hotkey_recorder_widget.dart | 134 +++++-- .../settings/keyboard_shortcuts_screen.dart | 3 +- lib/screens/settings/logs_screen.dart | 8 +- lib/screens/settings/mpv_config_screen.dart | 5 +- .../settings/seerr_settings_screen.dart | 3 +- .../settings/services_settings_screen.dart | 3 +- lib/screens/settings/settings_screen.dart | 23 +- .../tracker_account_settings_body.dart | 5 +- .../widgets/player_prompt_overlays.dart | 2 + lib/utils/dialogs.dart | 39 +- .../screens/watch_together_screen.dart | 7 +- .../widgets/join_session_dialog.dart | 1 + .../widgets/watch_together_overlay.dart | 333 +++++++++--------- lib/widgets/app_menu.dart | 2 +- lib/widgets/bottom_sheet_header.dart | 2 +- lib/widgets/cast_member_strip.dart | 15 +- lib/widgets/collapsible_text.dart | 73 +--- .../remote_session_dialog.dart | 9 +- lib/widgets/device_code_dialog.dart | 32 +- lib/widgets/dialog_action_button.dart | 33 +- lib/widgets/focusable_list_tile.dart | 82 +++++ lib/widgets/focusable_popup_menu_button.dart | 28 +- lib/widgets/focused_scroll_scaffold.dart | 25 +- lib/widgets/hotkey_recorder.dart | 12 +- lib/widgets/library_management_sheet.dart | 64 ++-- lib/widgets/media_context_menu.dart | 11 +- lib/widgets/music/mini_player.dart | 206 ++++++----- lib/widgets/oauth_proxy_dialog.dart | 32 +- lib/widgets/overlay_sheet.dart | 22 ++ lib/widgets/seerr_request_sheet.dart | 13 +- lib/widgets/setting_tile.dart | 209 ++++++----- lib/widgets/side_navigation_rail.dart | 12 +- lib/widgets/tv_color_picker.dart | 1 + lib/widgets/tv_number_spinner.dart | 1 + .../desktop_video_controls.dart | 2 + .../video_controls/mobile_video_controls.dart | 2 + .../sheets/subtitle_search_sheet.dart | 9 +- .../sheets/video_settings_sheet.dart | 6 +- .../widgets/sleep_timer_active_status.dart | 1 + .../widgets/sync_offset_control.dart | 46 +-- .../widgets/video_controls_header.dart | 12 +- test/focus/focusable_slider_test.dart | 67 ++++ test/focus/key_event_utils_test.dart | 26 ++ test/widgets/dialog_action_button_test.dart | 56 +++ test/widgets/focusable_list_tile_test.dart | 90 +++++ test/widgets/overlay_sheet_test.dart | 16 + test/widgets/side_navigation_rail_test.dart | 34 +- 82 files changed, 1860 insertions(+), 1216 deletions(-) create mode 100644 test/focus/focusable_slider_test.dart create mode 100644 test/widgets/dialog_action_button_test.dart create mode 100644 test/widgets/focusable_list_tile_test.dart diff --git a/lib/focus/dpad_select_long_press_controller.dart b/lib/focus/dpad_select_long_press_controller.dart index 4ac7d036..e3aabfcc 100644 --- a/lib/focus/dpad_select_long_press_controller.dart +++ b/lib/focus/dpad_select_long_press_controller.dart @@ -8,9 +8,7 @@ import 'dpad_navigator.dart'; /// Tracks the timer and physical key state for a D-pad SELECT long press. /// /// Focus loss, context-menu dispatch, and transferred/touch gesture suppression -/// stay with the caller because their behavior differs between widgets. The TV -/// guide program selector also stays local: it captures a program at key-down -/// and resets its state before opening program details. +/// stay with the caller because their behavior differs between widgets. class DpadSelectLongPressController { static const defaultDuration = Duration(milliseconds: 500); diff --git a/lib/focus/focusable_slider.dart b/lib/focus/focusable_slider.dart index 4104299c..d434e1e1 100644 --- a/lib/focus/focusable_slider.dart +++ b/lib/focus/focusable_slider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'dpad_navigator.dart'; import 'focusable_wrapper.dart'; @@ -10,9 +11,14 @@ class FocusableSlider extends StatefulWidget { final double max; final int? divisions; final ValueChanged? onChanged; + final ValueChanged? onChangeStart; + final ValueChanged? onChangeEnd; + final VoidCallback? onSelect; final FocusNode? focusNode; final bool autofocus; + final Color? activeColor; + final Color? inactiveColor; const FocusableSlider({ super.key, required this.value, @@ -20,8 +26,13 @@ class FocusableSlider extends StatefulWidget { this.max = 1.0, this.divisions, this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.onSelect, this.focusNode, this.autofocus = false, + this.activeColor, + this.inactiveColor, }); @override @@ -44,10 +55,16 @@ class _FocusableSliderState extends State { if (event.isActionable && widget.onChanged != null) { final delta = key.isRightKey ? _step : -_step; final newValue = (widget.value + delta).clamp(widget.min, widget.max); + widget.onChangeStart?.call(widget.value); widget.onChanged!(newValue); + widget.onChangeEnd?.call(newValue); } return KeyEventResult.handled; } + if (key.isSelectKey && event is KeyDownEvent && widget.onSelect != null) { + widget.onSelect!(); + return KeyEventResult.handled; + } return KeyEventResult.ignored; } @@ -74,6 +91,10 @@ class _FocusableSliderState extends State { max: widget.max, divisions: widget.divisions, onChanged: widget.onChanged, + onChangeStart: widget.onChangeStart, + onChangeEnd: widget.onChangeEnd, + activeColor: widget.activeColor, + inactiveColor: widget.inactiveColor, ), ), ); diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index 9253bf40..4f8ab2a9 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -32,24 +32,35 @@ import 'dpad_navigator.dart'; class BackKeyCoordinator { static bool _handledThisFrame = false; static bool _clearScheduled = false; + static int _clearGeneration = 0; static void markHandled() { _handledThisFrame = true; if (_clearScheduled) return; + _clearScheduled = true; - // Clear on next frame to avoid blocking unrelated future back presses. + final clearGeneration = _clearGeneration; WidgetsBinding.instance.addPostFrameCallback((_) { + if (clearGeneration != _clearGeneration) return; _handledThisFrame = false; _clearScheduled = false; }); + // addPostFrameCallback does not request a frame. Ensure the one-shot + // marker cannot leak when handling Back does not otherwise schedule one. + WidgetsBinding.instance.scheduleFrame(); + } + + static void clear() { + _handledThisFrame = false; + _clearScheduled = false; + // Invalidate a pending callback so it cannot clear a newer marker. + _clearGeneration++; } static bool consumeIfHandled() { - if (_handledThisFrame) { - _handledThisFrame = false; - return true; - } - return false; + if (!_handledThisFrame) return false; + clear(); + return true; } } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index e74b8fbd..2731ec10 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -336,6 +336,8 @@ "clearShortcut": "Clear shortcut", "noShortcutSet": "No shortcut set", "currentShortcut": "Current shortcut:", + "pressToRecord": "Select to record a shortcut", + "recordingShortcut": "Press the shortcut now", "actions": { "playPause": "Play/Pause", "volumeUp": "Volume Up", @@ -434,7 +436,9 @@ "hue": "Hue", "saturation": "Saturation", "brightness": "Brightness", - "hexColor": "Hex color" + "hexColor": "Hex color", + "expandText": "Expand text", + "collapseText": "Collapse text" }, "tooltips": { "shufflePlay": "Shuffle play", @@ -1048,6 +1052,8 @@ "hostingSession": "Hosting Session", "inSession": "In Session", "sessionCode": "Session Code", + "openSessionControls": "Open Watch Together session controls", + "copySessionCode": "Copy session code", "hostControlsPlayback": "Host controls playback", "anyoneCanControl": "Anyone can control playback", "hostControls": "Host controls", @@ -1358,6 +1364,8 @@ "artworkUpdated": "Artwork updated", "artworkUpdateFailed": "Failed to update artwork", "noArtworkAvailable": "No artwork available", + "artworkOption": "Artwork option ${index}", + "selectedArtworkOption": "Artwork option ${index}, selected", "notSet": "Not set", "libraryDefault": "Library default", "accountDefault": "Account default", @@ -1485,6 +1493,7 @@ "title": "Activate Plezy on ${service}", "body": "Visit ${url} and enter this code:", "openToActivate": "Open ${service} to activate", + "copyCode": "Copy activation code", "waitingForAuthorization": "Waiting for authorization…", "codeCopied": "Code copied" }, @@ -1492,6 +1501,7 @@ "title": "Sign in to ${service}", "body": "Scan this QR code or open the URL on any device.", "openToSignIn": "Open ${service} to sign in", + "copyUrl": "Copy sign-in URL", "urlCopied": "URL copied" }, "libraryFilter": { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 23300cee..2868ac1d 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 22514 (1407 per locale) +/// Strings: 22524 (1407 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 496c1613..e0dfc90d 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1127,6 +1127,12 @@ class TranslationsHotkeysEn { /// en: 'Current shortcut:' String get currentShortcut => 'Current shortcut:'; + /// en: 'Select to record a shortcut' + String get pressToRecord => 'Select to record a shortcut'; + + /// en: 'Press the shortcut now' + String get recordingShortcut => 'Press the shortcut now'; + late final TranslationsHotkeysActionsEn actions = TranslationsHotkeysActionsEn.internal(_root); } @@ -1356,6 +1362,12 @@ class TranslationsAccessibilityEn { /// en: 'Hex color' String get hexColor => 'Hex color'; + + /// en: 'Expand text' + String get expandText => 'Expand text'; + + /// en: 'Collapse text' + String get collapseText => 'Collapse text'; } // Path: tooltips @@ -3026,6 +3038,12 @@ class TranslationsWatchTogetherEn { /// en: 'Session Code' String get sessionCode => 'Session Code'; + /// en: 'Open Watch Together session controls' + String get openSessionControls => 'Open Watch Together session controls'; + + /// en: 'Copy session code' + String get copySessionCode => 'Copy session code'; + /// en: 'Host controls playback' String get hostControlsPlayback => 'Host controls playback'; @@ -3775,6 +3793,12 @@ class TranslationsMetadataEditEn { /// en: 'No artwork available' String get noArtworkAvailable => 'No artwork available'; + /// en: 'Artwork option ${index}' + String artworkOption({required Object index}) => 'Artwork option ${index}'; + + /// en: 'Artwork option ${index}, selected' + String selectedArtworkOption({required Object index}) => 'Artwork option ${index}, selected'; + /// en: 'Not set' String get notSet => 'Not set'; @@ -4836,6 +4860,9 @@ class TranslationsServicesDeviceCodeEn { /// en: 'Open ${service} to activate' String openToActivate({required Object service}) => 'Open ${service} to activate'; + /// en: 'Copy activation code' + String get copyCode => 'Copy activation code'; + /// en: 'Waiting for authorization…' String get waitingForAuthorization => 'Waiting for authorization…'; @@ -4860,6 +4887,9 @@ class TranslationsServicesOauthProxyEn { /// en: 'Open ${service} to sign in' String openToSignIn({required Object service}) => 'Open ${service} to sign in'; + /// en: 'Copy sign-in URL' + String get copyUrl => 'Copy sign-in URL'; + /// en: 'URL copied' String get urlCopied => 'URL copied'; } @@ -5239,6 +5269,8 @@ extension on Translations { 'hotkeys.clearShortcut' => 'Clear shortcut', 'hotkeys.noShortcutSet' => 'No shortcut set', 'hotkeys.currentShortcut' => 'Current shortcut:', + 'hotkeys.pressToRecord' => 'Select to record a shortcut', + 'hotkeys.recordingShortcut' => 'Press the shortcut now', 'hotkeys.actions.playPause' => 'Play/Pause', 'hotkeys.actions.volumeUp' => 'Volume Up', 'hotkeys.actions.volumeDown' => 'Volume Down', @@ -5328,6 +5360,8 @@ extension on Translations { 'accessibility.saturation' => 'Saturation', 'accessibility.brightness' => 'Brightness', 'accessibility.hexColor' => 'Hex color', + 'accessibility.expandText' => 'Expand text', + 'accessibility.collapseText' => 'Collapse text', 'tooltips.shufflePlay' => 'Shuffle play', 'tooltips.playTrailer' => 'Play trailer', 'tooltips.markAsWatched' => 'Mark as watched', @@ -5425,12 +5459,12 @@ extension on Translations { 'messages.fileInfoNotAvailable' => 'File information not available', 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}', 'messages.errorLoadingSeries' => 'Error loading series', + _ => null, + } ?? switch (path) { 'messages.musicNotSupported' => 'Music playback is not yet supported', 'messages.noDescriptionAvailable' => 'No description available', 'messages.noProfilesAvailable' => 'No profiles available', 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item', 'messages.logsCleared' => 'Logs cleared', 'messages.logsCopied' => 'Logs copied to clipboard', @@ -5876,6 +5910,8 @@ extension on Translations { 'watchTogether.hostingSession' => 'Hosting Session', 'watchTogether.inSession' => 'In Session', 'watchTogether.sessionCode' => 'Session Code', + 'watchTogether.openSessionControls' => 'Open Watch Together session controls', + 'watchTogether.copySessionCode' => 'Copy session code', 'watchTogether.hostControlsPlayback' => 'Host controls playback', 'watchTogether.anyoneCanControl' => 'Anyone can control playback', 'watchTogether.hostControls' => 'Host controls', @@ -5937,14 +5973,14 @@ extension on Translations { 'downloads.downloadNow' => 'Download', 'downloads.deleteDownload' => 'Delete download', 'downloads.retryDownload' => 'Retry download', + _ => null, + } ?? switch (path) { 'downloads.downloadQueued' => 'Download queued', 'downloads.downloadResumed' => 'Download resumed', 'downloads.serverErrorBitrate' => 'Server error: file may exceed the remote bitrate limit', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download', 'downloads.downloadDeleted' => 'Download deleted', 'downloads.deleteConfirm' => ({required Object title}) => 'Delete "${title}" from this device?', - _ => null, - } ?? switch (path) { 'downloads.cancelledDownloadTitle' => 'Cancelled Download', 'downloads.cancelledDownloadMessage' => 'This download was cancelled. What would you like to do?', 'downloads.allEpisodesAlreadyDownloaded' => 'All episodes already downloaded', @@ -6166,6 +6202,8 @@ extension on Translations { 'metadataEdit.artworkUpdated' => 'Artwork updated', 'metadataEdit.artworkUpdateFailed' => 'Failed to update artwork', 'metadataEdit.noArtworkAvailable' => 'No artwork available', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Artwork option ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Artwork option ${index}, selected', 'metadataEdit.notSet' => 'Not set', 'metadataEdit.libraryDefault' => 'Library default', 'metadataEdit.accountDefault' => 'Account default', @@ -6280,11 +6318,13 @@ extension on Translations { 'services.deviceCode.title' => ({required Object service}) => 'Activate Plezy on ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Visit ${url} and enter this code:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate', + 'services.deviceCode.copyCode' => 'Copy activation code', 'services.deviceCode.waitingForAuthorization' => 'Waiting for authorization…', 'services.deviceCode.codeCopied' => 'Code copied', 'services.oauthProxy.title' => ({required Object service}) => 'Sign in to ${service}', 'services.oauthProxy.body' => 'Scan this QR code or open the URL on any device.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Open ${service} to sign in', + 'services.oauthProxy.copyUrl' => 'Copy sign-in URL', 'services.oauthProxy.urlCopied' => 'URL copied', 'services.libraryFilter.title' => 'Library filter', 'services.libraryFilter.subtitleAllSyncing' => 'Syncing all libraries', diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index a6acdc24..d77a7600 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -319,6 +319,7 @@ class _AuthScreenState extends State { FocusableButton( autofocus: true, onPressed: busy ? null : startQr, + useBackgroundFocus: true, child: ElevatedButton( onPressed: busy ? null : startQr, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), @@ -347,6 +348,7 @@ class _AuthScreenState extends State { ] else ...[ FocusableButton( onPressed: busy ? null : startBrowser, + useBackgroundFocus: true, child: ElevatedButton.icon( onPressed: busy ? null : startBrowser, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), @@ -512,8 +514,8 @@ class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDis ], ), actions: [ - DialogActionButton(onPressed: _busy ? () {} : () => Navigator.of(context).pop(), label: t.common.cancel), - DialogActionButton(onPressed: _busy ? () {} : _submit, label: t.auth.authenticate, isPrimary: true), + DialogActionButton(onPressed: _busy ? null : () => Navigator.of(context).pop(), label: t.common.cancel), + DialogActionButton(onPressed: _busy ? null : _submit, label: t.auth.authenticate, isPrimary: true), ], ); } diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index f98f8347..76a434db 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -446,6 +446,11 @@ class _CatalogItemDetailScreenState extends State { ); } + void _handleSystemBack() { + if (BackKeyCoordinator.consumeIfHandled()) return; + Navigator.pop(context); + } + @override Widget build(BuildContext context) { final item = widget.item; @@ -459,151 +464,154 @@ class _CatalogItemDetailScreenState extends State { // remain route-driven. The overlay host always gets first refusal. return OverlaySheetHost( canPop: !blockSystemBack, - child: Focus( - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: Scaffold( - body: Stack( - children: [ - SingleChildScrollView( - key: const Key('catalog_detail_scroll'), - controller: _scrollController, - // The backdrop lives inside the scrollable so it moves with - // the content (it extends under the status bar, so the safe - // areas are baked into the content padding instead of a - // SafeArea around the scroll view). - child: Stack( - children: [ - if (item.backdropUrl != null) - Positioned( - top: 0, - left: 0, - right: 0, - height: 320, - child: ShaderMask( - shaderCallback: (rect) => LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.black, Colors.black.withValues(alpha: 0.0)], - stops: const [0.3, 1.0], - ).createShader(rect), - blendMode: BlendMode.dstIn, - child: OptimizedMediaImage.thumb( - imagePath: item.backdropUrl, - width: double.infinity, - height: 320, - fit: BoxFit.cover, - fallbackIcon: null, + onSystemBack: _handleSystemBack, + child: Builder( + builder: (hostContext) => Focus( + onKeyEvent: (_, event) => handleBackKeyNavigation(hostContext, event), + child: Scaffold( + body: Stack( + children: [ + SingleChildScrollView( + key: const Key('catalog_detail_scroll'), + controller: _scrollController, + // The backdrop lives inside the scrollable so it moves with + // the content (it extends under the status bar, so the safe + // areas are baked into the content padding instead of a + // SafeArea around the scroll view). + child: Stack( + children: [ + if (item.backdropUrl != null) + Positioned( + top: 0, + left: 0, + right: 0, + height: 320, + child: ShaderMask( + shaderCallback: (rect) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black, Colors.black.withValues(alpha: 0.0)], + stops: const [0.3, 1.0], + ).createShader(rect), + blendMode: BlendMode.dstIn, + child: OptimizedMediaImage.thumb( + imagePath: item.backdropUrl, + width: double.infinity, + height: 320, + fit: BoxFit.cover, + fallbackIcon: null, + ), ), ), - ), - Padding( - padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32), - child: Column( - crossAxisAlignment: .start, - children: [ - Row( - crossAxisAlignment: .start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210), - ), - const SizedBox(width: 20), - Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text( - item.title, - style: theme.textTheme.headlineMedium, - maxLines: 3, - overflow: .ellipsis, - ), - if (_metaLine.isNotEmpty) ...[ - const SizedBox(height: 8), + Padding( + padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32), + child: Column( + crossAxisAlignment: .start, + children: [ + Row( + crossAxisAlignment: .start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ Text( - _metaLine, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), - ), + item.title, + style: theme.textTheme.headlineMedium, + maxLines: 3, + overflow: .ellipsis, ), - ], - if (item.genres?.isNotEmpty ?? false) ...[ - const SizedBox(height: 8), - Text( - item.genres!.join(' • '), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + if (_metaLine.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _metaLine, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), ), - ), - ], - const SizedBox(height: 16), - if (_watchlistSource != null || _requestSource != null) - FocusableActionBar( - key: _actionBarKey, - onNavigateDown: _focusSectionBelowActions, - actions: [ - if (_watchlistSource != null) - FocusableAction( - icon: onWatchlist ?? false - ? Symbols.bookmark_added_rounded - : Symbols.bookmark_add_rounded, - tooltip: onWatchlist ?? false - ? t.explore.removeFromWatchlist - : t.explore.addToWatchlist, - onPressed: onWatchlist == null - ? () {} - : () => unawaited(_toggleWatchlist()), - ), - if (_requestSource case final SeerrCatalogSource seerr) - FocusableAction( - icon: Symbols.download_rounded, - tooltip: t.seerr.request, - onPressed: () => unawaited( - showSeerrRequestSheet( - context, - source: seerr, - kind: item.kind, - tmdbId: item.ids.tmdb!, - title: item.title, + ], + if (item.genres?.isNotEmpty ?? false) ...[ + const SizedBox(height: 8), + Text( + item.genres!.join(' • '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + const SizedBox(height: 16), + if (_watchlistSource != null || _requestSource != null) + FocusableActionBar( + key: _actionBarKey, + onNavigateDown: _focusSectionBelowActions, + actions: [ + if (_watchlistSource != null) + FocusableAction( + icon: onWatchlist ?? false + ? Symbols.bookmark_added_rounded + : Symbols.bookmark_add_rounded, + tooltip: onWatchlist ?? false + ? t.explore.removeFromWatchlist + : t.explore.addToWatchlist, + onPressed: onWatchlist == null + ? () {} + : () => unawaited(_toggleWatchlist()), + ), + if (_requestSource case final SeerrCatalogSource seerr) + FocusableAction( + icon: Symbols.download_rounded, + tooltip: t.seerr.request, + onPressed: () => unawaited( + showSeerrRequestSheet( + hostContext, + source: seerr, + kind: item.kind, + tmdbId: item.ids.tmdb!, + title: item.title, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), - ), + ], + ), + if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips], + const SizedBox(height: 24), + if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge), + const SizedBox(height: 24), + _buildLibrarySection(theme), + if (_cast case final List cast when cast.isNotEmpty) ...[ + const SizedBox(height: 28), + _buildCastSection(theme, cast), + ], + if (_related case final List related when related.isNotEmpty) ...[ + const SizedBox(height: 20), + _buildRelatedSection(related), ], - ), - if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips], - const SizedBox(height: 24), - if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge), - const SizedBox(height: 24), - _buildLibrarySection(theme), - if (_cast case final List cast when cast.isNotEmpty) ...[ - const SizedBox(height: 28), - _buildCastSection(theme, cast), ], - if (_related case final List related when related.isNotEmpty) ...[ - const SizedBox(height: 20), - _buildRelatedSection(related), - ], - ], + ), ), - ), - ], + ], + ), ), - ), - Positioned( - top: 0, - left: 0, - child: DesktopAppBarHelper.buildAdjustedLeading( - const AppBarBackButton(style: BackButtonStyle.circular), - context: context, - )!, - ), - ], + Positioned( + top: 0, + left: 0, + child: DesktopAppBarHelper.buildAdjustedLeading( + const AppBarBackButton(style: BackButtonStyle.circular), + context: hostContext, + )!, + ), + ], + ), ), ), ), diff --git a/lib/screens/catalog_search_screen.dart b/lib/screens/catalog_search_screen.dart index 61ff69d4..072801a1 100644 --- a/lib/screens/catalog_search_screen.dart +++ b/lib/screens/catalog_search_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/focusable_text_field.dart'; +import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../mixins/debounced_media_search.dart'; @@ -29,6 +30,7 @@ class CatalogSearchScreen extends StatefulWidget { } class _CatalogSearchScreenState extends State with DebouncedMediaSearch { + final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear'); @override String get searchDebugLabel => 'CatalogSearch'; @@ -44,6 +46,17 @@ class _CatalogSearchScreenState extends State with Debounce FocusUtils.requestFocusAfterBuild(this, searchFocusNode); } + @override + void dispose() { + _clearFocusNode.dispose(); + super.dispose(); + } + + void _clearSearch() { + searchController.clear(); + searchFocusNode.requestFocus(); + } + @override Widget build(BuildContext context) { final sourceName = widget.source.displayName; @@ -53,20 +66,33 @@ class _CatalogSearchScreenState extends State with Debounce SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - child: FocusableTextField( - controller: searchController, - focusNode: searchFocusNode, - textInputAction: TextInputAction.search, - onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, - onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, - decoration: pillInputDecoration( - context, - hintText: t.explore.searchHint(source: sourceName), - prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: searchController.text.isNotEmpty - ? IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: searchController.clear) - : null, - ), + child: Stack( + alignment: Alignment.centerRight, + children: [ + FocusableTextField( + controller: searchController, + focusNode: searchFocusNode, + textInputAction: TextInputAction.search, + onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, + onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, + onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, + decoration: pillInputDecoration( + context, + hintText: t.explore.searchHint(source: sourceName), + prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), + suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null, + ), + ), + if (searchController.text.isNotEmpty) + FocusableButton( + focusNode: _clearFocusNode, + onPressed: _clearSearch, + onNavigateLeft: searchFocusNode.requestFocus, + onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, + autoScroll: false, + child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch), + ), + ], ), ), ), diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 748a099d..d646e61e 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -135,7 +135,9 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { _sendCommand(RemoteCommandType.tabSearch); } final provider = context.read(); - OverlaySheetController.of(context).show(builder: (_) => _SearchBottomSheet(provider: provider)); + OverlaySheetController.of( + context, + ).show(showDragHandle: true, builder: (_) => _SearchBottomSheet(provider: provider)); } void _sendCommand(RemoteCommandType type) { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index d271e50d..3f49b52c 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -9,6 +9,7 @@ import '../widgets/server_activities_button.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; +import '../focus/focusable_button.dart'; import '../focus/hub_vertical_navigation.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; @@ -1202,7 +1203,12 @@ class _DiscoverScreenState extends State const SizedBox(height: 16), Text(_errorMessage!), const SizedBox(height: 16), - FilledButton(onPressed: _discover.load, child: Text(t.common.retry)), + FocusableButton( + autofocus: true, + onPressed: _discover.load, + useBackgroundFocus: true, + child: FilledButton(onPressed: _discover.load, child: Text(t.common.retry)), + ), ], ), ), diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 549479e7..6679d5f7 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -25,6 +25,7 @@ import '../widgets/app_menu.dart'; import '../widgets/catalog_source_logo.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hub_section.dart'; +import '../widgets/focusable_popup_menu_button.dart'; import '../widgets/settings_builder.dart'; import '../widgets/rasterized_gradient.dart'; import '../widgets/tv_browse_rail.dart'; @@ -154,26 +155,39 @@ class ExploreScreenState extends State CatalogSource active, { TextStyle? textStyle, AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start, + bool parentOwnsFocus = false, }) { - return AppMenuButton( - key: _sourceMenuKey, + final trigger = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: .min, + children: [ + CatalogSourceLogo(active.id, size: 22), + const SizedBox(width: 8), + Text(active.displayName, style: textStyle ?? Theme.of(context).textTheme.titleLarge), + const SizedBox(width: 4), + const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24), + ], + ), + ); + if (parentOwnsFocus) { + return AppMenuButton( + key: _sourceMenuKey, + tooltip: t.explore.selectSource, + anchorAlignment: anchorAlignment, + onSelected: (id) => unawaited(sources.setActiveSource(id)), + entriesBuilder: (context) => _sourceMenuEntries(sources, active), + child: trigger, + ); + } + return FocusablePopupMenuButton( + menuKey: _sourceMenuKey, tooltip: t.explore.selectSource, + semanticLabel: t.explore.selectSource, anchorAlignment: anchorAlignment, onSelected: (id) => unawaited(sources.setActiveSource(id)), - entriesBuilder: (context) => _sourceMenuEntries(sources, active), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - mainAxisSize: .min, - children: [ - CatalogSourceLogo(active.id, size: 22), - const SizedBox(width: 8), - Text(active.displayName, style: textStyle ?? Theme.of(context).textTheme.titleLarge), - const SizedBox(width: 4), - const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24), - ], - ), - ), + itemBuilder: (context) => _sourceMenuEntries(sources, active), + child: trigger, ); } @@ -352,6 +366,7 @@ class ExploreScreenState extends State context, ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), anchorAlignment: AppMenuAnchorAlignment.end, + parentOwnsFocus: true, ), ), if (active != null) diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 79c0aa57..42496b8b 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -24,6 +24,7 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/overlay_sheet.dart'; import '../focus/focusable_action_bar.dart'; +import '../focus/focusable_button.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; import 'libraries/sort_bottom_sheet.dart'; @@ -76,6 +77,7 @@ class _HubDetailScreenState extends State /// Key for getting a context below OverlaySheetHost final GlobalKey _overlayChildKey = GlobalKey(); + final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'hub_continuation_retry'); @override bool get hasItems => _filteredItems.isNotEmpty; @@ -120,6 +122,7 @@ class _HubDetailScreenState extends State @override void dispose() { _continuation.dispose(); + _continuationRetryFocusNode.dispose(); disposeFocusResources(); super.dispose(); } @@ -426,7 +429,13 @@ class _HubDetailScreenState extends State children: [ Text(error, textAlign: TextAlign.center), const SizedBox(height: 8), - TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)), + FocusableButton( + focusNode: _continuationRetryFocusNode, + onPressed: _retryHubContinuation, + onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), + onBack: handleBackFromContent, + child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)), + ), ], ), ), @@ -527,6 +536,11 @@ class _HubDetailScreenState extends State isInContinueWatching: widget.isInContinueWatching, usesContinueWatchingAction: widget.usesContinueWatchingAction, onNavigateUp: position.isFirstRow ? navigateToAppBar : null, + onNavigateDown: + _continuation.error != null && + position.index >= position.itemCount - position.columnCount + ? _continuationRetryFocusNode.requestFocus + : null, onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null, onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 1ff22b53..05a4af70 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -487,7 +487,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildBrowseOptionsSheet(sheetContext)); + controller.show(showDragHandle: true, builder: (sheetContext) => _buildBrowseOptionsSheet(sheetContext)); } /// Reset transient browse state before loading a different library. diff --git a/lib/screens/livetv/live_tv_actions_mixin.dart b/lib/screens/livetv/live_tv_actions_mixin.dart index a9edfd00..89593331 100644 --- a/lib/screens/livetv/live_tv_actions_mixin.dart +++ b/lib/screens/livetv/live_tv_actions_mixin.dart @@ -45,12 +45,14 @@ mixin LiveTvActionsMixin on State { /// Open the program-details bottom sheet. The poster is resolved from /// [posterThumb] on the server identified by [posterServerId]. void showProgramDetails({ + BuildContext? sheetContext, required LiveTvProgram program, required LiveTvChannel? channel, required String? posterThumb, required String? posterServerId, }) { - final multiServer = context.read(); + final effectiveContext = sheetContext ?? context; + final multiServer = effectiveContext.read(); final serverId = serverIdOrNull(posterServerId); final client = serverId == null ? null : multiServer.getClientForServer(serverId); String? posterUrl; @@ -60,13 +62,13 @@ mixin LiveTvActionsMixin on State { thumbPath: posterThumb, maxWidth: 80, maxHeight: 120, - devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context), + devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(effectiveContext), imageType: ImageType.poster, ); } showProgramDetailsSheet( - context, + effectiveContext, program: program, channel: channel, posterUrl: posterUrl, diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 449c2f71..ee98bf71 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -535,6 +535,8 @@ class _LiveTvScreenState extends State OverlaySheetController.showAdaptive( context, + isScrollControlled: true, + showDragHandle: true, builder: (sheetContext) => ReorderFavoritesSheet( favorites: List.from(_favoriteChannels), channelMap: channelMap, @@ -712,6 +714,7 @@ class _LiveTvScreenState extends State FocusableButton( autofocus: true, onPressed: _loadChannels, + useBackgroundFocus: true, child: FilledButton.icon( onPressed: _loadChannels, icon: const AppIcon(Symbols.refresh_rounded), diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 106eff2e..541c8e72 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -96,7 +96,7 @@ class _LiveTvShowScheduleScreenState extends State return client?.liveTvDvr != null; } - Future _onRecordShow() async { + Future _onRecordShow(BuildContext hostContext) async { final client = context.read().getClientForServer(ServerId(widget.serverId)); if (client == null) return; // Use the first program with a guid as the seed for `getSubscriptionTemplate`. @@ -110,7 +110,7 @@ class _LiveTvShowScheduleScreenState extends State } } if (seed == null) return; - await recordProgram(context, client, seed); + await recordProgram(hostContext, client, seed); } @override @@ -119,38 +119,42 @@ class _LiveTvShowScheduleScreenState extends State return OverlaySheetHost( // Close an open sheet on system back instead of popping the screen. canPop: true, - child: FocusedScrollScaffold( - title: Text(widget.showTitle), - actions: showRecord - ? [ - FocusableActionBar( - actions: [ - FocusableAction( - icon: Symbols.fiber_manual_record_rounded, - tooltip: t.liveTv.recordShow, - onPressed: _onRecordShow, - ), + child: Builder( + builder: (hostContext) => FocusedScrollScaffold( + title: Text(widget.showTitle), + actions: showRecord + ? [ + FocusableActionBar( + actions: [ + FocusableAction( + icon: Symbols.fiber_manual_record_rounded, + tooltip: t.liveTv.recordShow, + onPressed: () => _onRecordShow(hostContext), + ), + ], + ), + ] + : null, + slivers: [ + if (_isLoading) + LoadingIndicatorBox.sliver + else if (_programs.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) + else + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index, hostContext), ], ), - ] - : null, - slivers: [ - if (_isLoading) - LoadingIndicatorBox.sliver - else if (_programs.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.liveTv.noPrograms))) - else - SliverToBoxAdapter( - child: SettingsGroup( - children: [for (var index = 0; index < _programs.length; index++) _buildScheduleItem(index)], ), - ), - ], + ], + ), ), ); } - Widget _buildScheduleItem(int index) { + Widget _buildScheduleItem(int index, BuildContext hostContext) { final program = _programs[index]; final channel = findChannelForProgram(program); void onTap() { @@ -158,6 +162,7 @@ class _LiveTvShowScheduleScreenState extends State tuneChannel(channel); } else { showProgramDetails( + sheetContext: hostContext, program: program, channel: channel, posterThumb: program.thumb, @@ -173,7 +178,7 @@ class _LiveTvShowScheduleScreenState extends State useBackgroundFocus: true, disableScale: true, onSelect: onTap, - onBack: () => Navigator.pop(context), + onBack: () => Navigator.pop(hostContext), child: _ScheduleListTile(program: program, channel: channel, onTap: onTap), ); } diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index a059fc97..d837529e 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -37,6 +37,7 @@ void showProgramDetailsSheet( }) { OverlaySheetController.showAdaptive( context, + showDragHandle: true, builder: (sheetContext) { return _ProgramDetailsSheetContent( program: program, @@ -257,6 +258,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent onNavigateRight: index < total - 1 ? () => _focusButton(index + 1) : null, onNavigateUp: onNavigateUp, onBack: _closeSheet, + useBackgroundFocus: true, child: child, ); } diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index 86301796..4251ce14 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -44,6 +44,8 @@ class RecordOptionsSheet { }) { return OverlaySheetController.pushAdaptive( context, + isScrollControlled: true, + showDragHandle: true, builder: (sheetContext) => _RecordOptionsContent(client: client, headerTitle: program.displayTitle, entries: entries), ); @@ -58,6 +60,8 @@ class RecordOptionsSheet { }) { return OverlaySheetController.pushAdaptive( context, + isScrollControlled: true, + showDragHandle: true, builder: (sheetContext) => _RecordOptionsContent( client: client, headerTitle: rule.title ?? '', @@ -343,6 +347,7 @@ class _RecordOptionsContentState extends State<_RecordOptionsContent> { FocusableButton( focusNode: _saveFocusNode, onPressed: _saving ? null : _save, + useBackgroundFocus: true, child: FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index d402798b..83dd05e7 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -227,6 +227,7 @@ class _ReorderFavoritesSheetState extends State { Expanded( child: Focus( focusNode: _listFocusNode, + descendantsAreFocusable: false, autofocus: isKeyboardMode, onKeyEvent: _handleKeyEvent, child: ReorderableListView.builder( diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 349abdb5..feb89d3a 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../../focus/dpad_navigator.dart'; +import '../../../focus/dpad_select_long_press_controller.dart'; import '../../../focus/focus_theme.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../focus/key_event_utils.dart'; @@ -30,7 +31,6 @@ import '../../../utils/platform_detector.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/app_menu.dart'; import '../../../widgets/clickable_cursor.dart'; -import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/optimized_media_image.dart'; import '../livetv_styles.dart'; import '../program_details_sheet.dart'; @@ -81,7 +81,6 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi static const _sourceHeaderRowHeight = 40.0; static const _timeHeaderHeight = 40.0; static const _minutesPerSlot = 30; - static const _longPressDuration = Duration(milliseconds: 500); /// Minimum time away (backgrounded or on another section) before the /// viewport is realigned to the live line on return. @@ -101,7 +100,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi bool _syncingScroll = false; Timer? _timeIndicatorTimer; - Timer? _programSelectLongPressTimer; + final _programSelectController = DpadSelectLongPressController(); final _dayPickerKey = GlobalKey(); // Stale-window catch-up state (#1297). The grid window is only auto @@ -121,7 +120,6 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi final ValueNotifier _hasFocusNotifier = ValueNotifier(false); LiveTvProgram? _focusedProgram; bool _pendingFocus = false; - bool _isProgramSelectKeyDown = false; /// Focus into the guide content (called from tab bar navigation or initial load). void focusContent() { @@ -217,7 +215,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _programSelectLongPressTimer?.cancel(); + _programSelectController.dispose(); _guideFocusNode.dispose(); _gridVerticalController.dispose(); _gridHorizontalController.removeListener(_syncGridToHeader); @@ -237,10 +235,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi _hasFocusNotifier.value = hasFocus; } - void _resetProgramSelectLongPressState() { - _programSelectLongPressTimer?.cancel(); - _isProgramSelectKeyDown = false; - } + void _resetProgramSelectLongPressState() => _programSelectController.reset(); void _syncGridToHeader() { if (_syncingScroll) return; @@ -580,39 +575,18 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } KeyEventResult _handleFocusedProgramSelectKey(KeyEvent event) { - if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored; final target = _focusedProgramTarget(); if (target == null) return KeyEventResult.ignored; - if (event is KeyDownEvent) { - if (!_isProgramSelectKeyDown) { - _isProgramSelectKeyDown = true; - _programSelectLongPressTimer?.cancel(); - _programSelectLongPressTimer = Timer(_longPressDuration, () { - if (!mounted || !_isProgramSelectKeyDown) return; - SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); - _resetProgramSelectLongPressState(); - _showProgramDetails(target.channel, target.program); - }); - } - return KeyEventResult.handled; - } - - if (event is KeyRepeatEvent) { - return KeyEventResult.handled; - } - - if (event is KeyUpEvent) { - final timerWasActive = _programSelectLongPressTimer?.isActive ?? false; - _programSelectLongPressTimer?.cancel(); - if (timerWasActive && _isProgramSelectKeyDown) { - _activateProgram(target.channel, target.program); - } - _isProgramSelectKeyDown = false; - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; + return _programSelectController.handleKeyEvent( + event, + isOwnerActive: () => mounted && _focusedProgramTarget() == target, + onShortPress: () => _activateProgram(target.channel, target.program), + onLongPress: () { + _programSelectController.reset(); + _showProgramDetails(target.channel, target.program); + }, + ); } KeyEventResult _handleFocusedProgramContextMenuKey(KeyEvent event) { @@ -875,13 +849,11 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi return const Center(child: CircularProgressIndicator()); } - return OverlaySheetHost( - child: Focus( - focusNode: _guideFocusNode, - onFocusChange: _handleGuideFocusChange, - onKeyEvent: _handleKeyEvent, - child: _buildGuideGrid(theme), - ), + return Focus( + focusNode: _guideFocusNode, + onFocusChange: _handleGuideFocusChange, + onKeyEvent: _handleKeyEvent, + child: _buildGuideGrid(theme), ); } diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 2f3ec830..baec2475 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -18,7 +18,6 @@ import '../../../utils/app_logger.dart'; import '../../../utils/dialogs.dart'; import '../../../utils/formatters.dart'; import '../../../widgets/app_icon.dart'; -import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/settings_section.dart'; import '../live_tv_refresh_lifecycle.dart'; import '../livetv_recording_actions.dart'; @@ -265,42 +264,40 @@ class RecordingsTabState extends State with WidgetsBindingObserve return Center(child: _EmptyMessage(text: t.liveTv.noScheduledRecordings)); } - return OverlaySheetHost( - child: ListView( - padding: const EdgeInsets.only(bottom: 8), - children: [ - if (grabs.isNotEmpty) - SettingsGroup( - title: t.liveTv.scheduledRecordings, - children: [ - for (var i = 0; i < grabs.length; i++) - _GrabTile( - entry: grabs[i], - autofocus: i == 0, - focusNode: i == 0 ? _firstTileFocusNode : null, - onTap: () => _onCancelGrab(grabs[i]), - onNavigateUp: i == 0 ? widget.onNavigateUp : null, - onBack: widget.onBack, - ), - ], - ), - if (rules.isNotEmpty) - SettingsGroup( - title: t.liveTv.recordingRules, - children: [ - for (var i = 0; i < rules.length; i++) - _RuleTile( - entry: rules[i], - autofocus: grabs.isEmpty && i == 0, - focusNode: grabs.isEmpty && i == 0 ? _firstTileFocusNode : null, - onTap: () => _onRuleTap(rules[i]), - onNavigateUp: grabs.isEmpty && i == 0 ? widget.onNavigateUp : null, - onBack: widget.onBack, - ), - ], - ), - ], - ), + return ListView( + padding: const EdgeInsets.only(bottom: 8), + children: [ + if (grabs.isNotEmpty) + SettingsGroup( + title: t.liveTv.scheduledRecordings, + children: [ + for (var i = 0; i < grabs.length; i++) + _GrabTile( + entry: grabs[i], + autofocus: i == 0, + focusNode: i == 0 ? _firstTileFocusNode : null, + onTap: () => _onCancelGrab(grabs[i]), + onNavigateUp: i == 0 ? widget.onNavigateUp : null, + onBack: widget.onBack, + ), + ], + ), + if (rules.isNotEmpty) + SettingsGroup( + title: t.liveTv.recordingRules, + children: [ + for (var i = 0; i < rules.length; i++) + _RuleTile( + entry: rules[i], + autofocus: grabs.isEmpty && i == 0, + focusNode: grabs.isEmpty && i == 0 ? _firstTileFocusNode : null, + onTap: () => _onRuleTap(rules[i]), + onNavigateUp: grabs.isEmpty && i == 0 ? widget.onNavigateUp : null, + onBack: widget.onBack, + ), + ], + ), + ], ); } } diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 7bbae440..7409cdb8 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -17,7 +17,6 @@ import '../../../providers/multi_server_provider.dart'; import '../../../services/settings_service.dart'; import '../../../utils/app_logger.dart'; import '../../../widgets/hub_section.dart'; -import '../../../widgets/overlay_sheet.dart'; import '../live_tv_actions_mixin.dart'; import '../live_tv_show_schedule_screen.dart'; import '../live_tv_refresh_lifecycle.dart'; @@ -202,35 +201,33 @@ class WhatsOnTabState extends State return Center(child: Text(t.liveTv.noPrograms)); } - return OverlaySheetHost( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - clipBehavior: Clip.none, - itemCount: _hubs.length, - itemBuilder: (context, index) { - final hub = _hubs[index]; - return HubSection( - key: _hubKeys[index], - hub: hub.mediaHub, - icon: Symbols.live_tv_rounded, - cardSizing: HubCardSizing.grid, - episodePosterModeOverride: EpisodePosterMode.seriesPoster, - onItemTap: (item) => _onItemTap(hub.entryFor(item)), - onItemLongPress: (item) { - final entry = hub.entryFor(item); - showProgramDetails( - program: entry.program, - channel: findChannelForProgram(entry.program), - posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath, - posterServerId: entry.metadata.serverId, - ); - }, - onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), - onNavigateToSidebar: widget.onBack, - onBack: widget.onBack, - ); - }, - ), + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + clipBehavior: Clip.none, + itemCount: _hubs.length, + itemBuilder: (context, index) { + final hub = _hubs[index]; + return HubSection( + key: _hubKeys[index], + hub: hub.mediaHub, + icon: Symbols.live_tv_rounded, + cardSizing: HubCardSizing.grid, + episodePosterModeOverride: EpisodePosterMode.seriesPoster, + onItemTap: (item) => _onItemTap(hub.entryFor(item)), + onItemLongPress: (item) { + final entry = hub.entryFor(item); + showProgramDetails( + program: entry.program, + channel: findChannelForProgram(entry.program), + posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath, + posterServerId: entry.metadata.serverId, + ); + }, + onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onNavigateToSidebar: widget.onBack, + onBack: widget.onBack, + ); + }, ); } } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 96846830..9be1b2f9 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -768,6 +768,11 @@ class _MediaDetailScreenState extends State Navigator.pop(context, _watchStateChanged); } + void _handleMediaDetailSystemBack() { + if (BackKeyCoordinator.consumeIfHandled()) return; + _popMediaDetailIfBackNotSuppressed(); + } + bool _isTvDetailReadyToReveal(MediaItem metadata) { if (_isLoadingMetadata) return false; if (!_hasLoadedTvDetailSupplementalSections(metadata)) return false; @@ -1082,8 +1087,8 @@ class _MediaDetailScreenState extends State } void _showRatingDialog(BuildContext sheetContext, MediaItem metadata) { - OverlaySheetController.showAdaptive( - sheetContext, + OverlaySheetController.of(sheetContext).show( + showDragHandle: true, builder: (context) => RatingBottomSheet( item: metadata, serverClient: _getMediaClientForMetadata(this.context), @@ -2196,50 +2201,6 @@ class _MediaDetailScreenState extends State /// the rendered cards can never disagree. double _getResponsiveCardWidth() => CastMemberStrip.responsiveCardWidth(context); - /// Handle key events for the overview section - KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - if (key.isBackKey) return KeyEventResult.ignored; - if (!event.isActionable) return KeyEventResult.ignored; - - final metadata = _fullMetadata ?? _metadata; - - // UP: always play button (overview is directly below play) - if (key.isUpKey) { - _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); - _playButtonFocusNode.requestFocus(); - return KeyEventResult.handled; - } - - if (key.isDownKey) { - if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { - _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); - _scrollSectionIntoView(_seasonsSectionKey); - } else if (_episodes.isNotEmpty) { - _firstEpisodeFocusNode.requestFocus(); - _scrollSectionIntoView(_seasonsSectionKey); - } else if (metadata.roles != null && metadata.roles!.isNotEmpty) { - _castStripKey.currentState?.requestFocus(); - _scrollSectionIntoView(_castSectionKey); - } else if (_extras != null && _extras!.isNotEmpty) { - _extrasFocusNode.requestFocus(); - _scrollSectionIntoView(_extrasSectionKey); - } else if (_relatedHubs.isNotEmpty) { - _relatedHubKeys.first.currentState?.requestFocusFromMemory(); - } else if (_hasInfoRows) { - _focusInfoRows(); - } - return KeyEventResult.handled; - } - - // LEFT/RIGHT/SELECT: consume to prevent unwanted traversal - if (key.isLeftKey || key.isRightKey || key.isSelectKey) { - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - /// Show context menu for a season tab void _showSeasonTabContextMenu(int index, {Offset? position}) { final key = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey()); @@ -2536,7 +2497,7 @@ class _MediaDetailScreenState extends State } /// Handle key events for the trailing info rows (studio / contentRating). - /// UP returns to the previous focusable section; all other directions consume. + /// UP returns to the previous focusable section; terminal geometry is trapped. KeyEventResult _handleInfoRowsKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; if (key.isBackKey) return KeyEventResult.ignored; @@ -2547,8 +2508,10 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // DOWN / LEFT / RIGHT / SELECT: consume — info rows are the terminal row. - return KeyEventResult.handled; + if (key.isDownKey || key.isLeftKey || key.isRightKey || key.isSelectKey) { + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } IconData _getRelatedHubIcon(MediaHub hub) { @@ -3120,8 +3083,9 @@ class _MediaDetailScreenState extends State } return PopScope( canPop: false, // Prevent system back from double-popping on Android keyboard/TV - // ignore: no-empty-block - required callback, blocks system back on Android TV - onPopInvokedWithResult: (didPop, result) {}, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) _handleMediaDetailSystemBack(); + }, child: loading, ); } @@ -3146,6 +3110,7 @@ class _MediaDetailScreenState extends State // keyboard/TV (the key handler owns dpad back); elsewhere canPop:true // keeps the iOS swipe-back. The host also closes an open sheet on back. canPop: !blockSystemBack, + onSystemBack: _handleMediaDetailSystemBack, child: Focus( onKeyEvent: _handleMediaDetailBackKey, child: Scaffold( @@ -3176,40 +3141,23 @@ class _MediaDetailScreenState extends State if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[ Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle), const SizedBox(height: 12), - Focus( + CollapsibleText( + text: metadata.summary!, + maxLines: isMobile ? 6 : 4, + style: theme.textTheme.bodyLarge?.copyWith(height: 1.6), focusNode: _overviewFocusNode, - onKeyEvent: _handleOverviewKeyEvent, - child: ListenableBuilder( - listenable: _overviewFocusNode, - builder: (context, _) { - final showFocus = - _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: Border.all( - color: showFocus - ? theme.colorScheme.primary.withValues(alpha: 0.5) - : Colors.transparent, - width: 2, - ), - ), - child: () { - final summaryStyle = theme.textTheme.bodyLarge?.copyWith(height: 1.6); - if (isTv) { - return Text(metadata.summary!, style: summaryStyle); - } - return CollapsibleText( - text: metadata.summary!, - maxLines: isMobile ? 6 : 4, - style: summaryStyle, - ); - }(), - ); - }, - ), + skipTraversal: false, + onNavigateUp: () { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + _playButtonFocusNode.requestFocus(); + }, + onNavigateDown: _focusBelowActionRow, + onNavigateLeft: () {}, + onNavigateRight: () {}, ), const SizedBox(height: 24), ], @@ -3451,6 +3399,7 @@ class _MediaDetailScreenState extends State // blockSystemBack keeps the route from double-popping on Android keyboard/ // TV (the key handler owns dpad back); the host also closes an open sheet. canPop: !blockSystemBack, + onSystemBack: _handleMediaDetailSystemBack, child: Focus( onKeyEvent: handleBack, child: Scaffold( diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index c406a5eb..efde06f7 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -3,8 +3,8 @@ import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../focus/focusable_button.dart'; import '../focus/focusable_wrapper.dart'; +import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../metadata_edit/metadata_edit_adapters.dart'; @@ -274,11 +274,19 @@ class _MetadataEditScreenState extends State { final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList(); return FocusedScrollScaffold( title: Text(t.metadataEdit.screenTitle), + focusableAppBarActions: true, actions: [ if (_isSaving) const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24)) else - IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)), + FocusableButton( + onPressed: _hasChanges ? _save : null, + child: IconButton( + onPressed: _hasChanges ? _save : null, + icon: const AppIcon(Symbols.check_rounded, fill: 1), + tooltip: t.common.save, + ), + ), ], slivers: [ SliverPadding( @@ -492,27 +500,17 @@ class _ArtworkPickerDialogState extends State { ), actions: [ if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)), - FocusableButton( + DialogActionButton( onPressed: _addFromUrl, - child: TextButton.icon( - onPressed: _addFromUrl, - icon: const AppIcon(Symbols.link_rounded, size: 18), - label: Text(t.metadataEdit.fromUrl), - ), + label: t.metadataEdit.fromUrl, + icon: const AppIcon(Symbols.link_rounded, size: 18), ), - FocusableButton( + DialogActionButton( onPressed: _uploadFile, - child: TextButton.icon( - onPressed: _uploadFile, - icon: const AppIcon(Symbols.upload_rounded, size: 18), - label: Text(t.metadataEdit.uploadFile), - ), - ), - FocusableButton( - autofocus: true, - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + label: t.metadataEdit.uploadFile, + icon: const AppIcon(Symbols.upload_rounded, size: 18), ), + DialogActionButton(autofocus: true, onPressed: () => Navigator.pop(context), label: t.common.cancel), ], ); } @@ -533,6 +531,9 @@ class _ArtworkPickerDialogState extends State { final artwork = _artworkList![index]; return FocusableWrapper( borderRadius: 8, + semanticLabel: artwork.selected + ? t.metadataEdit.selectedArtworkOption(index: index + 1) + : t.metadataEdit.artworkOption(index: index + 1), onSelect: () => _selectArtwork(artwork), child: GestureDetector( onTap: () => _selectArtwork(artwork), diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index 6ed733eb..09efb262 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -452,19 +452,27 @@ class _ArtistLinkState extends State<_ArtistLink> { if (widget.onTap == null) return Text(widget.name, style: style); final showFocus = _focused && InputModeTracker.isKeyboardMode(context); - return Focus( - focusNode: _focusNode, - onFocusChange: (hasFocus) => setState(() => _focused = hasFocus), - onKeyEvent: dpadKeyHandler(onSelect: widget.onTap), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: FocusTheme.getAnimationDuration(context), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus), - child: Text(widget.name, style: style), + return Semantics( + button: true, + enabled: true, + label: widget.name, + onTap: widget.onTap, + child: ExcludeSemantics( + child: Focus( + focusNode: _focusNode, + onFocusChange: (hasFocus) => setState(() => _focused = hasFocus), + onKeyEvent: dpadKeyHandler(onSelect: widget.onTap), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: FocusTheme.getAnimationDuration(context), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus), + child: Text(widget.name, style: style), + ), + ), ), ), ), diff --git a/lib/screens/music/now_playing_screen.dart b/lib/screens/music/now_playing_screen.dart index 3db197bf..3cf2e608 100644 --- a/lib/screens/music/now_playing_screen.dart +++ b/lib/screens/music/now_playing_screen.dart @@ -10,6 +10,8 @@ import 'package:provider/provider.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/focus_theme.dart'; import '../../focus/focusable_action_bar.dart'; +import '../../focus/focusable_button.dart'; +import '../../focus/focusable_slider.dart'; import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; @@ -466,10 +468,13 @@ class _NowPlayingScreenState extends State // Inset past the macOS traffic lights — this screen is a fullscreen // route, so the close button would otherwise sit underneath them. DesktopAppBarHelper.buildAdjustedLeading( - IconButton( - icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text), - tooltip: t.common.close, + FocusableButton( onPressed: _pop, + child: IconButton( + icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text), + tooltip: t.common.close, + onPressed: _pop, + ), ), context: context, )!, @@ -502,15 +507,18 @@ class _NowPlayingScreenState extends State final cluster = Row( mainAxisSize: .min, children: [ - IconButton( - icon: AppIcon( - Symbols.lyrics_rounded, - fill: 1, - size: 22, - color: _showLyrics ? colorScheme.primary : tk.textMuted, - ), - tooltip: t.music.lyrics, + FocusableButton( onPressed: _toggleLyrics, + child: IconButton( + icon: AppIcon( + Symbols.lyrics_rounded, + fill: 1, + size: 22, + color: _showLyrics ? colorScheme.primary : tk.textMuted, + ), + tooltip: t.music.lyrics, + onPressed: _toggleLyrics, + ), ), if (PlatformDetector.isDesktop(context)) ...[const SizedBox(width: 4), _buildVolumeCluster(service)], ], @@ -558,7 +566,7 @@ class _NowPlayingScreenState extends State thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), ), - child: Slider( + child: FocusableSlider( value: volume.clamp(0.0, 100.0), max: 100, onChanged: (value) => unawaited(service.setVolume(value, persist: false)), @@ -576,39 +584,7 @@ class _NowPlayingScreenState extends State /// entry. On TV ([focusable]) it joins the d-pad chain above the seek bar. Widget _buildOverflowButton(MediaItem track, {bool focusable = false}) { final tk = tokens(context); - final button = IconButton( - icon: AppIcon(Symbols.more_vert_rounded, fill: 1, color: tk.text), - onPressed: () => contextMenuKey.currentState?.showContextMenu(context), - ); - - Widget child = button; - if (focusable) { - child = ListenableBuilder( - listenable: _overflowFocusNode, - builder: (context, _) { - final showFocus = _overflowFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - return Focus( - focusNode: _overflowFocusNode, - descendantsAreFocusable: false, - onKeyEvent: (node, event) { - final backResult = handleBackKeyAction(event, _pop); - if (backResult != KeyEventResult.ignored) return backResult; - return dpadKeyHandler( - onSelect: () => contextMenuKey.currentState?.showContextMenu(context), - onDown: _seekFocusNode.requestFocus, - onUp: () {}, - trapHorizontalEdges: true, - )(node, event); - }, - child: AnimatedContainer( - duration: FocusTheme.getAnimationDuration(context), - decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: 20), - child: button, - ), - ); - }, - ); - } + void showMenu() => contextMenuKey.currentState?.showContextMenu(context); return MediaContextMenu( key: contextMenuKey, @@ -616,7 +592,21 @@ class _NowPlayingScreenState extends State extraEntries: [ MediaMenuExtraEntry(icon: Symbols.bedtime_rounded, label: t.music.sleepTimer, onSelected: _showSleepTimerSheet), ], - child: child, + child: FocusableButton( + focusNode: focusable ? _overflowFocusNode : null, + onPressed: showMenu, + onNavigateDown: focusable ? _seekFocusNode.requestFocus : null, + onNavigateUp: focusable ? () {} : null, + onNavigateLeft: focusable ? () {} : null, + onNavigateRight: focusable ? () {} : null, + onBack: focusable ? _pop : null, + useBackgroundFocus: focusable, + child: IconButton( + icon: AppIcon(Symbols.more_vert_rounded, fill: 1, color: tk.text), + onPressed: showMenu, + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + ), + ), ); } diff --git a/lib/screens/music/queue_sheet.dart b/lib/screens/music/queue_sheet.dart index 16287f5f..39ab4ff3 100644 --- a/lib/screens/music/queue_sheet.dart +++ b/lib/screens/music/queue_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_action_bar.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; import '../../services/music/music_playback_service.dart'; @@ -19,7 +20,7 @@ import '../../widgets/overlay_sheet.dart'; /// [OverlaySheetHost] ancestor (all now-playing layouts do) so TV back /// handling stays centralized in the host. Future showQueueSheet(BuildContext context) { - return OverlaySheetController.showAdaptive(context, showDragHandle: true, builder: (_) => const QueueSheet()); + return OverlaySheetController.of(context).show(showDragHandle: true, builder: (_) => const QueueSheet()); } /// Sheet chrome around [QueueList]: header with track count, shuffle/repeat @@ -48,30 +49,27 @@ class QueueSheet extends StatelessWidget { style: TextStyle(fontSize: 13, color: tk.textMuted), ), ), - IconButton( - icon: AppIcon( - Symbols.shuffle_rounded, - fill: 1, - size: 20, - color: service.shuffled ? colorScheme.primary : tk.textMuted, - ), - tooltip: t.common.shuffle, - onPressed: service.toggleShuffle, - ), - IconButton( - icon: AppIcon( - repeatModeIcon(service.repeatMode), - fill: 1, - size: 20, - color: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary, - ), - tooltip: repeatModeLabel(service.repeatMode), - onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)), - ), - IconButton( - icon: AppIcon(Symbols.clear_all_rounded, fill: 1, size: 20, color: tk.textMuted), - tooltip: t.music.clearQueue, - onPressed: service.clearUpcoming, + FocusableActionBar( + actions: [ + FocusableAction( + icon: Symbols.shuffle_rounded, + iconColor: service.shuffled ? colorScheme.primary : tk.textMuted, + tooltip: t.common.shuffle, + onPressed: service.toggleShuffle, + ), + FocusableAction( + icon: repeatModeIcon(service.repeatMode), + iconColor: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary, + tooltip: repeatModeLabel(service.repeatMode), + onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)), + ), + FocusableAction( + icon: Symbols.clear_all_rounded, + iconColor: tk.textMuted, + tooltip: t.music.clearQueue, + onPressed: service.clearUpcoming, + ), + ], ), ], ), diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 99cd1d95..b38abf60 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_action_bar.dart'; +import '../../focus/focusable_button.dart'; import '../../media/library_query.dart'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; @@ -187,6 +188,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen with MountedS isActive: isActive && !widget.requireSelection, chips: _chipsFor(profile, view), onTap: () => _switchTo(profile), + onLongPress: hasMenu ? () => _openProfileMenu(profile) : null, // 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). @@ -371,6 +372,7 @@ class _ProfileTile extends StatelessWidget { final BorderRadius borderRadius; final List<_ChipData> chips; final VoidCallback onTap; + final VoidCallback? onLongPress; final VoidCallback? onManage; final VoidCallback? onDelete; final VoidCallback? onSignOut; @@ -384,6 +386,7 @@ class _ProfileTile extends StatelessWidget { required this.borderRadius, required this.chips, required this.onTap, + this.onLongPress, this.onManage, this.onDelete, this.onSignOut, @@ -397,7 +400,9 @@ class _ProfileTile extends StatelessWidget { final theme = Theme.of(context); final hasMenu = onManage != null || onDelete != null || onSignOut != null; return InkWell( + canRequestFocus: false, onTap: isActive ? null : onTap, + onLongPress: onLongPress, borderRadius: borderRadius, child: Padding( padding: const EdgeInsets.all(12), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 114e1d33..eed25a3b 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_text_field.dart'; +import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../mixins/debounced_media_search.dart'; @@ -32,6 +33,7 @@ class _SearchScreenState extends State with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch { String? _focusResultsForQuery; final _tvKeyboardController = TvKeyboardController(); + final _clearFocusNode = FocusNode(debugLabel: 'Search.clear'); @override void initState() { @@ -39,6 +41,17 @@ class _SearchScreenState extends State FocusUtils.requestFocusAfterBuild(this, searchFocusNode); } + @override + void dispose() { + _clearFocusNode.dispose(); + super.dispose(); + } + + void _clearSearch() { + searchController.clear(); + searchFocusNode.requestFocus(); + } + @override String get searchDebugLabel => 'Search'; @@ -192,34 +205,46 @@ class _SearchScreenState extends State SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - child: FocusableTextField( - controller: searchController, - focusNode: searchFocusNode, - tvKeyboardController: _tvKeyboardController, - textInputAction: TextInputAction.search, - onNavigateLeft: _navigateToSidebar, - onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, - onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, - onBack: () { - if (searchController.text.isNotEmpty) { - searchController.clear(); - } else { - _navigateToSidebar(); - } - }, - decoration: pillInputDecoration( - context, - hintText: t.search.hint, - prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: searchController.text.isNotEmpty - ? IconButton( - icon: const AppIcon(Symbols.clear_rounded, fill: 1), - onPressed: () { - searchController.clear(); - }, - ) - : null, - ), + child: Stack( + alignment: Alignment.centerRight, + children: [ + FocusableTextField( + controller: searchController, + focusNode: searchFocusNode, + tvKeyboardController: _tvKeyboardController, + textInputAction: TextInputAction.search, + onNavigateLeft: _navigateToSidebar, + onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, + onNavigateDown: searchResults.isNotEmpty && !isSearching + ? firstResultFocusNode.requestFocus + : null, + onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, + onBack: () { + if (searchController.text.isNotEmpty) { + searchController.clear(); + } else { + _navigateToSidebar(); + } + }, + decoration: pillInputDecoration( + context, + hintText: t.search.hint, + prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), + suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null, + ), + ), + if (searchController.text.isNotEmpty) + FocusableButton( + focusNode: _clearFocusNode, + onPressed: _clearSearch, + onNavigateLeft: searchFocusNode.requestFocus, + onNavigateDown: searchResults.isNotEmpty && !isSearching + ? firstResultFocusNode.requestFocus + : null, + autoScroll: false, + child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch), + ), + ], ), ), ), diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index 25c2e95b..dc0036d9 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_section.dart'; import '../../i18n/strings.g.dart'; import 'licenses_screen.dart'; @@ -56,7 +57,7 @@ class AboutScreen extends StatelessWidget { SettingsGroup( margin: EdgeInsets.zero, children: [ - ListTile( + FocusableListTile( leading: const AppIcon(Symbols.description_rounded, fill: 1), title: Text(t.about.openSourceLicenses), subtitle: Text(t.about.viewLicensesDescription), diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 3387b025..6ef45d1c 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -16,6 +16,7 @@ import '../../focus/focusable_slider.dart'; import '../../services/device_performance.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_page.dart'; import '../../widgets/settings_builder.dart'; @@ -224,7 +225,7 @@ class AppearanceSettingsScreen extends StatelessWidget { } Widget _languageSelector(BuildContext context) { - return ListTile( + return FocusableListTile( leading: const AppIcon(Symbols.language_rounded, fill: 1), title: Text(t.settings.language), subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index eb7e7061..050e743a 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/widgets/app_icon.dart'; +import '../../widgets/dialog_action_button.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; import '../../i18n/strings.g.dart'; @@ -12,6 +13,7 @@ import '../../models/external_player_models.dart'; import '../../services/settings_service.dart'; import '../../utils/dialogs.dart'; import '../../widgets/expressive_button_group.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_page.dart'; @@ -57,7 +59,7 @@ class ExternalPlayerScreen extends StatelessWidget { title: t.externalPlayer.customPlayers, children: [ for (final p in custom) _PlayerTile(player: p, selectedId: selected.id, isCustom: true), - ListTile( + FocusableListTile( leading: const AppIcon(Symbols.add_rounded, fill: 1), title: Text(t.externalPlayer.addCustomPlayer), onTap: () => _showAddCustomPlayerDialog(context), @@ -105,17 +107,20 @@ class _PlayerTile extends StatelessWidget { leading = const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32); } - return ListTile( + return FocusableListTile( leading: leading, title: Text(player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name), trailing: Row( mainAxisSize: .min, children: [ - if (isCustom) - IconButton( + FocusableButton( + onPressed: () => svc.removeCustomExternalPlayer(player.id), + autoScroll: false, + child: IconButton( icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20), onPressed: () => svc.removeCustomExternalPlayer(player.id), ), + ), AppIcon( isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, @@ -231,15 +236,8 @@ class _AddCustomPlayerDialogState extends State<_AddCustomPlayerDialog> { ), ), actions: [ - FocusableButton( - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - FocusableButton( - focusNode: _saveFocusNode, - onPressed: _submit, - child: FilledButton(onPressed: _submit, child: Text(t.common.save)), - ), + DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel), + DialogActionButton(focusNode: _saveFocusNode, onPressed: _submit, label: t.common.save, isPrimary: true), ], ); } diff --git a/lib/screens/settings/hotkey_recorder_widget.dart b/lib/screens/settings/hotkey_recorder_widget.dart index 4b784cf1..b1e7d108 100644 --- a/lib/screens/settings/hotkey_recorder_widget.dart +++ b/lib/screens/settings/hotkey_recorder_widget.dart @@ -4,6 +4,9 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../models/hotkey_model.dart'; import '../../widgets/hotkey_recorder.dart'; import '../../i18n/strings.g.dart'; +import '../../focus/focusable_button.dart'; +import '../../focus/focusable_wrapper.dart'; +import '../../widgets/dialog_action_button.dart'; class HotKeyRecorderWidget extends StatefulWidget { final String actionName; @@ -25,6 +28,11 @@ class HotKeyRecorderWidget extends StatefulWidget { class _HotKeyRecorderWidgetState extends State { HotKey? _recordedHotKey; + bool _isCapturing = false; + final _recorderFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.record'); + final _clearFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.clear'); + final _cancelFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.cancel'); + final _saveFocusNode = FocusNode(debugLabel: 'HotKeyRecorder.save'); @override void initState() { @@ -32,8 +40,43 @@ class _HotKeyRecorderWidgetState extends State { _recordedHotKey = widget.currentHotKey; } + @override + void dispose() { + _recorderFocusNode.dispose(); + _clearFocusNode.dispose(); + _cancelFocusNode.dispose(); + _saveFocusNode.dispose(); + super.dispose(); + } + + void _startCapturing() { + setState(() => _isCapturing = true); + _recorderFocusNode.requestFocus(); + } + + void _handleHotKeyRecorded(HotKey hotKey) { + setState(() { + _recordedHotKey = hotKey; + _isCapturing = false; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _saveFocusNode.requestFocus(); + }); + } + + void _clearShortcut() { + setState(() { + _recordedHotKey = null; + _isCapturing = false; + }); + _recorderFocusNode.requestFocus(); + } + @override Widget build(BuildContext context) { + final hasShortcut = _recordedHotKey != null; + final recordLabel = _isCapturing ? t.hotkeys.recordingShortcut : t.hotkeys.pressToRecord; + return AlertDialog( title: Text(t.hotkeys.setShortcutFor(actionName: widget.actionName)), content: SizedBox( @@ -48,43 +91,62 @@ class _HotKeyRecorderWidgetState extends State { style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .bold), ), const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - child: Row( - children: [ - Expanded( - child: HotKeyRecorder( - initalHotKey: _recordedHotKey, - onHotKeyRecorded: (hotKey) { - setState(() { - _recordedHotKey = hotKey; - }); - }, + Row( + children: [ + Expanded( + child: FocusableWrapper( + focusNode: _recorderFocusNode, + autofocus: true, + onSelect: _startCapturing, + onBack: widget.onCancel, + onNavigateRight: hasShortcut ? _clearFocusNode.requestFocus : null, + onNavigateDown: (hasShortcut ? _saveFocusNode : _cancelFocusNode).requestFocus, + semanticLabel: recordLabel, + descendantsAreFocusable: false, + useBackgroundFocus: true, + child: GestureDetector( + onTap: _startCapturing, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide(color: Theme.of(context).dividerColor)), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: hasShortcut + ? HotKeyRecorder( + initalHotKey: _recordedHotKey, + enabled: _isCapturing, + onHotKeyRecorded: _handleHotKeyRecorded, + ) + : Text(recordLabel), + ), ), ), - if (_recordedHotKey != null) - IconButton( + ), + if (hasShortcut) ...[ + const SizedBox(width: 8), + FocusableButton( + focusNode: _clearFocusNode, + onPressed: _clearShortcut, + onBack: widget.onCancel, + onNavigateLeft: _recorderFocusNode.requestFocus, + onNavigateDown: _saveFocusNode.requestFocus, + autoScroll: false, + child: IconButton( icon: const AppIcon(Symbols.backspace_rounded, fill: 1, size: 18), - onPressed: () { - setState(() { - _recordedHotKey = null; - }); - }, + onPressed: _clearShortcut, padding: .zero, constraints: const BoxConstraints(minWidth: 24, minHeight: 24), tooltip: t.hotkeys.clearShortcut, ), + ), ], - ), + ], ), const SizedBox(height: 8), Text( - 'Press any key combination to set a new shortcut', + recordLabel, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)), @@ -95,10 +157,22 @@ class _HotKeyRecorderWidgetState extends State { ), ), actions: [ - TextButton(onPressed: widget.onCancel, child: Text(t.common.cancel)), - TextButton( - onPressed: _recordedHotKey != null ? () => widget.onHotKeyRecorded(_recordedHotKey!) : null, - child: Text(t.common.save), + DialogActionButton( + focusNode: _cancelFocusNode, + onPressed: widget.onCancel, + onBack: widget.onCancel, + onNavigateUp: _recorderFocusNode.requestFocus, + onNavigateRight: _saveFocusNode.requestFocus, + label: t.common.cancel, + ), + DialogActionButton( + focusNode: _saveFocusNode, + onPressed: hasShortcut ? () => widget.onHotKeyRecorded(_recordedHotKey!) : null, + onBack: widget.onCancel, + onNavigateUp: _recorderFocusNode.requestFocus, + onNavigateLeft: _cancelFocusNode.requestFocus, + label: t.common.save, + isPrimary: true, ), ], ); diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index 14bd157e..01f3f6c1 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -9,6 +9,7 @@ import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../theme/mono_tokens.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_section.dart'; import 'hotkey_recorder_widget.dart'; @@ -47,7 +48,7 @@ class KeyboardShortcutsScreen extends StatelessWidget { child: SettingsGroup( children: [ for (final action in actions) - ListTile( + FocusableListTile( title: Text(keyboardService.getActionDisplayName(action)), subtitle: Text(action), trailing: Container( diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 447f7751..ea4f4536 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -10,7 +10,7 @@ import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/focusable_button.dart'; +import '../../widgets/dialog_action_button.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/mounted_set_state_mixin.dart'; @@ -216,11 +216,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { ], ), actions: [ - FocusableButton( - autofocus: true, - onPressed: () => Navigator.of(ctx).pop(), - child: TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close)), - ), + DialogActionButton(autofocus: true, onPressed: () => Navigator.of(ctx).pop(), label: t.common.close), ], ), ), diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index 597f95a6..515e92e6 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -16,6 +16,7 @@ import '../../services/settings_service.dart'; import '../../widgets/app_menu.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focusable_popup_menu_button.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; @@ -199,7 +200,7 @@ class _MpvConfigScreenState extends State with SettingsEffectMi // The page already pads its slivers by 16. margin: EdgeInsets.zero, children: [ - ListTile( + FocusableListTile( focusNode: _savePresetFocusNode, leading: const AppIcon(Symbols.save_rounded, fill: 1), title: Text(t.mpvConfig.saveAsPreset), @@ -208,7 +209,7 @@ class _MpvConfigScreenState extends State with SettingsEffectMi ), if (presets.isNotEmpty) ...presets.map( - (preset) => ListTile( + (preset) => FocusableListTile( leading: const AppIcon(Symbols.folder_rounded, fill: 1), title: Text(preset.name), trailing: FocusablePopupMenuButton( diff --git a/lib/screens/settings/seerr_settings_screen.dart b/lib/screens/settings/seerr_settings_screen.dart index 9e47d191..f01f0421 100644 --- a/lib/screens/settings/seerr_settings_screen.dart +++ b/lib/screens/settings/seerr_settings_screen.dart @@ -9,6 +9,7 @@ import '../../models/seerr/seerr_session.dart'; import '../../providers/seerr_account_provider.dart'; import '../../utils/dialogs.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_page.dart'; import '../../widgets/settings_section.dart'; @@ -71,7 +72,7 @@ class SeerrSettingsScreen extends StatelessWidget { const SizedBox(height: 24), SettingsGroup( children: [ - ListTile( + FocusableListTile( leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error), title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)), onTap: () => unawaited(_disconnect(context, account)), diff --git a/lib/screens/settings/services_settings_screen.dart b/lib/screens/settings/services_settings_screen.dart index 114a2c2e..abc7dbda 100644 --- a/lib/screens/settings/services_settings_screen.dart +++ b/lib/screens/settings/services_settings_screen.dart @@ -9,6 +9,7 @@ import '../../providers/trakt_account_provider.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/catalog_source_logo.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_section.dart'; import 'seerr_connect_screen.dart'; import 'seerr_settings_screen.dart'; @@ -144,7 +145,7 @@ class _ServiceHubRow extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( + return FocusableListTile( leading: leading, title: Text(title), subtitle: Text(username != null ? t.services.connectedAs(username: username!) : t.services.notConnected), diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index e9f859ab..9788e5d0 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -36,6 +36,7 @@ import '../../utils/platform_detector.dart'; import '../../utils/update_dialog.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/dialog_action_button.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/library_management_sheet.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; @@ -190,7 +191,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildDonateTile() { - return ListTile( + return FocusableListTile( focusNode: _focusTracker.get(_kDonate), leading: const AppIcon(Symbols.favorite_rounded, fill: 1), title: Text(t.settings.supportDeveloper), @@ -328,7 +329,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun future: storageService.getCurrentDownloadPathDisplay(), builder: (context, snapshot) { final currentPath = snapshot.data ?? '...'; - return ListTile( + return FocusableListTile( focusNode: _focusTracker.get(_kDownloadLocation), leading: const AppIcon(Symbols.folder_rounded, fill: 1), title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault), @@ -389,7 +390,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun return SettingsGroup( title: t.settings.advanced, children: [ - ListTile( + FocusableListTile( focusNode: _focusTracker.get(_kWatchTogetherRelay), leading: const AppIcon(Symbols.dns_rounded, fill: 1), title: Text(t.settings.watchTogetherRelay), @@ -418,7 +419,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun subtitle: t.settings.viewLogsDescription, destinationBuilder: (context) => const LogsScreen(), ), - ListTile( + FocusableListTile( focusNode: _focusTracker.get(_kClearCache), leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1), title: Text(t.settings.clearCache), @@ -426,7 +427,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showClearCacheDialog(), ), - ListTile( + FocusableListTile( focusNode: _focusTracker.get(_kResetSettings), leading: const AppIcon(Symbols.restore_rounded, fill: 1), title: Text(t.settings.resetSettings), @@ -435,7 +436,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun onTap: () => _showResetSettingsDialog(), ), if (kDebugMode) - ListTile( + FocusableListTile( leading: const AppIcon(Symbols.error_rounded, fill: 1), title: const Text('Test Sentry'), subtitle: const Text('Send a test error'), @@ -445,7 +446,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun }, ), if (kDebugMode) - ListTile( + FocusableListTile( leading: const AppIcon(Symbols.timer_rounded, fill: 1), title: const Text('Test ANR'), subtitle: const Text('Block the main thread for 10 seconds'), @@ -464,7 +465,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun return SettingsGroup( title: t.settings.backup, children: [ - ListTile( + FocusableListTile( focusNode: _focusTracker.get(_kExportSettings), leading: const AppIcon(Symbols.upload_rounded, fill: 1), title: Text(t.settings.exportSettings), @@ -472,7 +473,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: _handleExportSettings, ), - ListTile( + FocusableListTile( focusNode: _focusTracker.get(_kImportSettings), leading: const AppIcon(Symbols.download_rounded, fill: 1), title: Text(t.settings.importSettings), @@ -497,7 +498,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun return SettingsGroup( title: t.settings.updates, children: [ - ListTile( + FocusableListTile( focusNode: _focusTracker.get(_kCheckForUpdates), leading: const AppIcon(Symbols.system_update_rounded, fill: 1), title: Text(t.settings.checkForUpdates), @@ -514,7 +515,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun return SettingsGroup( title: t.settings.updates, children: [ - ListTile( + FocusableListTile( focusNode: _focusTracker.get(_kCheckForUpdates), leading: AppIcon( hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded, diff --git a/lib/screens/settings/tracker_account_settings_body.dart b/lib/screens/settings/tracker_account_settings_body.dart index 31b88c62..b488fef3 100644 --- a/lib/screens/settings/tracker_account_settings_body.dart +++ b/lib/screens/settings/tracker_account_settings_body.dart @@ -7,6 +7,7 @@ import '../../i18n/strings.g.dart'; import '../../services/settings_service.dart'; import '../../services/trackers/tracker_constants.dart'; import '../../widgets/app_icon.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_page.dart'; @@ -76,7 +77,7 @@ class TrackerAccountSettingsBody extends StatelessWidget { prefs: [SettingsService.trackerFilterModePref(service), SettingsService.trackerFilterIdsPref(service)], builder: (context) { final settings = SettingsService.instance; - return ListTile( + return FocusableListTile( leading: const AppIcon(Symbols.filter_list_rounded, fill: 1), title: Text(t.services.libraryFilter.title), subtitle: Text(TrackerLibraryFilterScreen.subtitleFor(settings, service)), @@ -92,7 +93,7 @@ class TrackerAccountSettingsBody extends StatelessWidget { const SizedBox(height: 24), SettingsGroup( children: [ - ListTile( + FocusableListTile( leading: AppIcon(Symbols.link_off_rounded, fill: 1, color: Theme.of(context).colorScheme.error), title: Text(t.common.disconnect, style: TextStyle(color: Theme.of(context).colorScheme.error)), onTap: () => unawaited(Future.sync(onDisconnect)), diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index 12cf3594..da1a85d4 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -237,6 +237,7 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { onNavigateLeft: () => cancelFocusNode.requestFocus(), onNavigateUp: () {}, onNavigateDown: () {}, + useBackgroundFocus: true, child: FilledButton( onPressed: onPlayNext, style: FilledButton.styleFrom( @@ -401,6 +402,7 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { onNavigateLeft: () => pauseFocusNode.requestFocus(), onNavigateUp: () {}, onNavigateDown: () {}, + useBackgroundFocus: true, child: FilledButton( onPressed: onContinue, style: FilledButton.styleFrom( diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 7cb26b33..e5795f99 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../focus/focusable_button.dart'; import '../focus/focusable_text_field.dart'; import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; @@ -47,24 +46,24 @@ Future showConfirmDialog( title: Text(title), content: Text(message), actions: [ - FocusableButton( + DialogActionButton( autofocus: true, onPressed: () => Navigator.pop(dialogContext, false), - child: TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), - child: Text(cancelText ?? t.common.cancel), - ), + label: cancelText ?? t.common.cancel, + style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), ), - FocusableButton( + DialogActionButton( onPressed: () => Navigator.pop(dialogContext, true), - child: FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - style: isDestructive - ? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError) - : null, - child: Text(confirmText), - ), + label: confirmText, + isPrimary: true, + style: isDestructive + ? FilledButton.styleFrom( + padding: _buttonPadding, + shape: _buttonShape, + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ) + : FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), ), ], ); @@ -93,14 +92,12 @@ Future showServerLimitDialog(BuildContext context) async { title: Text(t.messages.serverLimitTitle), content: Text(t.messages.serverLimitBody), actions: [ - FocusableButton( + DialogActionButton( autofocus: true, onPressed: () => Navigator.of(ctx).pop(), - child: FilledButton( - onPressed: () => Navigator.of(ctx).pop(), - style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), - child: Text(t.common.close), - ), + label: t.common.close, + isPrimary: true, + style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), ), ], ), diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 02448589..1d99078b 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -171,6 +171,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta child: FocusableButton( autofocus: _recentRooms.isEmpty, onPressed: _isBusy ? null : _createSession, + useBackgroundFocus: true, child: FilledButton.icon( onPressed: _isBusy ? null : _createSession, icon: _isCreating ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.add_rounded), @@ -431,6 +432,7 @@ class _RecentRoomTile extends StatelessWidget { : null, trailing: IconButton(icon: const Icon(Symbols.more_vert_rounded), onPressed: () => _showActions(context)), onTap: isBusy ? null : onTap, + onLongPress: () => _showActions(context), ), ), ), @@ -438,8 +440,8 @@ class _RecentRoomTile extends StatelessWidget { } void _showActions(BuildContext context) { - OverlaySheetController.showAdaptive( - context, + OverlaySheetController.of(context).show( + showDragHandle: true, builder: (context) => AppMenuSheet( entries: [ AppMenuItem(value: 'rename', icon: Symbols.edit_rounded, label: t.watchTogether.renameRoom), @@ -714,6 +716,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { child: FocusableButton( autofocus: true, onPressed: _isJoining ? null : _joinCurrentPlayback, + useBackgroundFocus: true, child: FilledButton.icon( onPressed: _isJoining ? null : _joinCurrentPlayback, icon: _isJoining ? const LoadingIndicatorBox() : const Icon(Symbols.play_arrow_rounded), diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index bb845b57..2459c6dd 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -112,6 +112,7 @@ class _JoinSessionDialogState extends State with ControllerDi focusNode: _joinFocusNode, onPressed: _join, onNavigateUp: _sessionIdFocusNode.requestFocus, + useBackgroundFocus: true, child: FilledButton.icon( onPressed: _join, icon: const Icon(Symbols.group_add), diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 94ad1582..4275a082 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -5,19 +5,24 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; +import '../../widgets/bottom_sheet_header.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/overlay_sheet.dart'; import '../models/watch_session.dart'; import '../providers/watch_together_provider.dart'; class WatchTogetherSessionIndicator extends StatelessWidget { final VoidCallback? onLeaveSession; + final VoidCallback? onCancelAutoHide; + final VoidCallback? onStartAutoHide; - const WatchTogetherSessionIndicator({super.key, this.onLeaveSession}); + const WatchTogetherSessionIndicator({super.key, this.onLeaveSession, this.onCancelAutoHide, this.onStartAutoHide}); @override Widget build(BuildContext context) { @@ -36,8 +41,14 @@ class WatchTogetherSessionIndicator extends StatelessWidget { } void _showSessionMenu(BuildContext context, WatchTogetherProvider provider) { - OverlaySheetController.of(context).show( - builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession), + onCancelAutoHide?.call(); + unawaited( + OverlaySheetController.of(context) + .show( + showDragHandle: true, + builder: (context) => _SessionMenuSheet(provider: provider, onLeaveSession: onLeaveSession), + ) + .whenComplete(() => onStartAutoHide?.call()), ); } } @@ -61,53 +72,55 @@ class _SessionIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: Colors.black54, - borderRadius: const BorderRadius.all(Radius.circular(20)), - child: InkWell( - onTap: onTap, + return FocusableWrapper( + onSelect: onTap, + semanticLabel: t.watchTogether.openSessionControls, + descendantsAreFocusable: false, + borderRadius: 20, + useBackgroundFocus: true, + child: Material( + color: Colors.black54, borderRadius: const BorderRadius.all(Radius.circular(20)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: .min, - children: [ - // Sync indicator or group icon - if (isSyncing) - SizedBox( - width: 16, - height: 16, - child: PlatformDetector.isTV() - ? const Icon(Symbols.sync_rounded, size: 16, color: Colors.white) - : const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - else - Icon(Symbols.group, size: 18, color: isHost ? Colors.amber : Colors.white), - - const SizedBox(width: 6), - - // Participant count - Text( - '$participantCount', - style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 14), - ), - - // Host badge - if (isHost) ...[ + child: InkWell( + canRequestFocus: false, + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(20)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: .min, + children: [ + if (isSyncing) + SizedBox( + width: 16, + height: 16, + child: PlatformDetector.isTV() + ? const Icon(Symbols.sync_rounded, size: 16, color: Colors.white) + : const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + else + Icon(Symbols.group, size: 18, color: isHost ? Colors.amber : Colors.white), const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: const BoxDecoration( - color: Colors.amber, - borderRadius: BorderRadius.all(Radius.circular(4)), - ), - child: Text( - t.watchTogether.hostBadge, - style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: .bold), - ), + Text( + '$participantCount', + style: const TextStyle(color: Colors.white, fontWeight: .bold, fontSize: 14), ), + if (isHost) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: const BoxDecoration( + color: Colors.amber, + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + child: Text( + t.watchTogether.hostBadge, + style: const TextStyle(color: Colors.black, fontSize: 10, fontWeight: .bold), + ), + ), + ], ], - ], + ), ), ), ), @@ -125,131 +138,119 @@ class _SessionMenuSheet extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - children: [ - // Header - Row( - children: [ - Icon(Symbols.group, color: theme.colorScheme.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text(t.watchTogether.title, style: theme.textTheme.titleMedium), - Text( - provider.isHost ? t.watchTogether.youAreHost : t.watchTogether.watchingWithOthers, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + return Column( + mainAxisSize: .min, + children: [ + BottomSheetHeader( + title: t.watchTogether.title, + icon: Symbols.group, + iconColor: theme.colorScheme.primary, + action: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: Text( + provider.controlMode == ControlMode.hostOnly + ? t.watchTogether.hostControls + : t.watchTogether.anyoneControls, + style: theme.textTheme.labelSmall, + ), + ), + ), + Flexible( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + provider.isHost ? t.watchTogether.youAreHost : t.watchTogether.watchingWithOthers, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + if (provider.sessionId != null) ...[ + const SizedBox(height: 12), + FocusableWrapper( + onSelect: () => _copySessionCode(context, provider.sessionId!), + semanticLabel: t.watchTogether.copySessionCode, + descendantsAreFocusable: false, + borderRadius: 8, + useBackgroundFocus: true, + child: InkWell( + canRequestFocus: false, + onTap: () => _copySessionCode(context, provider.sessionId!), + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - ], - ), - ), - // Control mode badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - child: Text( - provider.controlMode == ControlMode.hostOnly - ? t.watchTogether.hostControls - : t.watchTogether.anyoneControls, - style: theme.textTheme.labelSmall, + child: Row( + mainAxisSize: .min, + children: [ + Text( + '${t.watchTogether.sessionCode}: ', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + Text( + provider.sessionId!, + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontWeight: .bold), + ), + const SizedBox(width: 8), + Icon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant), + ], + ), + ), ), ), ], - ), - - // Session code with copy button - if (provider.sessionId != null) ...[ - const SizedBox(height: 12), - InkWell( - onTap: () => _copySessionCode(context, provider.sessionId!), - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Row( - mainAxisSize: .min, - children: [ - Text( - '${t.watchTogether.sessionCode}: ', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - Text( - provider.sessionId!, - style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontWeight: .bold), - ), - const SizedBox(width: 8), - Icon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant), - ], + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + Text(t.watchTogether.participants, style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + for (final participant in provider.participants) + ListTile( + leading: CircleAvatar( + backgroundColor: participant.isHost + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + child: Icon( + participant.isHost ? Symbols.star : Symbols.person, + color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant, + size: 20, + ), ), + title: Text(participant.displayName), + subtitle: participant.isHost ? Text(t.watchTogether.host) : null, + trailing: participant.isBuffering + ? SizedBox( + width: 16, + height: 16, + child: PlatformDetector.isTV() + ? const Icon(Symbols.hourglass_empty_rounded, size: 16) + : const CircularProgressIndicator(strokeWidth: 2), + ) + : null, + dense: true, + contentPadding: .zero, ), - ), - ], - - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - - // Participants list - Text(t.watchTogether.participants, style: theme.textTheme.titleSmall), - const SizedBox(height: 8), - ...provider.participants.map( - (p) => ListTile( - leading: CircleAvatar( - backgroundColor: p.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest, - child: Icon( - p.isHost ? Symbols.star : Symbols.person, - color: p.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant, - size: 20, - ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + FocusableListTile( + leading: Icon(Symbols.logout, color: theme.colorScheme.error), + title: Text( + provider.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession, + style: TextStyle(color: theme.colorScheme.error), ), - title: Text(p.displayName), - subtitle: p.isHost ? Text(t.watchTogether.host) : null, - trailing: p.isBuffering - ? SizedBox( - width: 16, - height: 16, - child: PlatformDetector.isTV() - ? const Icon(Symbols.hourglass_empty_rounded, size: 16) - : const CircularProgressIndicator(strokeWidth: 2), - ) - : null, - dense: true, + onTap: () => unawaited(_confirmLeave(context)), contentPadding: .zero, ), - ), - - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - - // Actions - ListTile( - leading: Icon(Symbols.logout, color: theme.colorScheme.error), - title: Text( - provider.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession, - style: TextStyle(color: theme.colorScheme.error), - ), - onTap: () { - OverlaySheetController.of(context).close(); - _confirmLeave(context); - }, - contentPadding: .zero, - ), - ], + ], + ), ), - ), + ], ); } @@ -258,7 +259,7 @@ class _SessionMenuSheet extends StatelessWidget { showSuccessSnackBar(context, t.watchTogether.sessionCodeCopied); } - void _confirmLeave(BuildContext context) async { + Future _confirmLeave(BuildContext context) async { final confirmed = await showConfirmDialog( context, title: provider.isHost ? t.watchTogether.endSessionQuestion : t.watchTogether.leaveSessionQuestion, @@ -267,10 +268,10 @@ class _SessionMenuSheet extends StatelessWidget { isDestructive: true, ); - if (confirmed) { - unawaited(provider.leaveSession()); - onLeaveSession?.call(); - } + if (!confirmed || !context.mounted) return; + OverlaySheetController.closeAdaptive(context); + unawaited(provider.leaveSession()); + onLeaveSession?.call(); } } diff --git a/lib/widgets/app_menu.dart b/lib/widgets/app_menu.dart index f129671a..fc6c1c84 100644 --- a/lib/widgets/app_menu.dart +++ b/lib/widgets/app_menu.dart @@ -387,7 +387,7 @@ class _AppMenuItemTileState extends State> with FocusableT final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final enabled = item.enabled && widget.onPressed != null; - final active = enabled && (_isFocused || _isHovered); + final active = enabled && ((_isFocused && InputModeTracker.isKeyboardMode(context)) || _isHovered); final foreground = _foregroundColor(context, active: active); final subtitleColor = foreground.withValues(alpha: active && item.stateLayerColor != null ? 0.86 : 0.68); final background = _backgroundColor(context, active: active); diff --git a/lib/widgets/bottom_sheet_header.dart b/lib/widgets/bottom_sheet_header.dart index 0f89ab32..c282d164 100644 --- a/lib/widgets/bottom_sheet_header.dart +++ b/lib/widgets/bottom_sheet_header.dart @@ -18,7 +18,7 @@ class BottomSheetHeader extends StatelessWidget { final Widget? action; /// Optional callback when close button is pressed - /// Defaults to Navigator.pop(context) + /// Defaults to closing the nearest hosted sheet, with modal-route fallback. final VoidCallback? onClose; /// Optional icon to display as leading widget diff --git a/lib/widgets/cast_member_strip.dart b/lib/widgets/cast_member_strip.dart index 678063dc..1679f282 100644 --- a/lib/widgets/cast_member_strip.dart +++ b/lib/widgets/cast_member_strip.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../focus/card_focus_scope.dart'; import '../focus/dpad_navigator.dart'; +import '../focus/key_event_utils.dart'; import '../media/media_server_client.dart'; import '../services/settings_service.dart'; import '../theme/mono_tokens.dart'; @@ -132,8 +133,14 @@ class CastMemberStripState extends State { KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; - if (key.isBackKey || !event.isActionable) return KeyEventResult.ignored; - if (widget.members.isEmpty) return KeyEventResult.ignored; + if (key.isBackKey || widget.members.isEmpty) return KeyEventResult.ignored; + + final onMemberTap = widget.onMemberTap; + if (onMemberTap != null) { + final selectResult = handleOneShotSelect(event, () => onMemberTap(_focusedIndex)); + if (selectResult != KeyEventResult.ignored) return selectResult; + } + if (!event.isActionable) return KeyEventResult.ignored; if (key.isLeftKey) { _moveFocus(-1); @@ -151,10 +158,6 @@ class CastMemberStripState extends State { widget.onNavigateDown!(); return KeyEventResult.handled; } - if (key.isSelectKey && widget.onMemberTap != null) { - widget.onMemberTap!(_focusedIndex); - return KeyEventResult.handled; - } return KeyEventResult.ignored; } diff --git a/lib/widgets/collapsible_text.dart b/lib/widgets/collapsible_text.dart index b1aa89cb..b04fe2d2 100644 --- a/lib/widgets/collapsible_text.dart +++ b/lib/widgets/collapsible_text.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import '../focus/dpad_navigator.dart'; -import '../focus/input_mode_tracker.dart'; -import '../focus/key_event_utils.dart'; +import '../focus/focusable_wrapper.dart'; +import '../i18n/strings.g.dart'; import 'clickable_cursor.dart'; class CollapsibleText extends StatefulWidget { @@ -54,33 +53,6 @@ class _CollapsibleTextState extends State { }); } - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final selectResult = handleOneShotSelect(event, _toggleExpanded); - if (selectResult != KeyEventResult.ignored) return selectResult; - - if (!event.isActionable) return KeyEventResult.ignored; - - final key = event.logicalKey; - if (key.isUpKey && widget.onNavigateUp != null) { - widget.onNavigateUp!(); - return KeyEventResult.handled; - } - if (key.isDownKey && widget.onNavigateDown != null) { - widget.onNavigateDown!(); - return KeyEventResult.handled; - } - if (key.isLeftKey && widget.onNavigateLeft != null) { - widget.onNavigateLeft!(); - return KeyEventResult.handled; - } - if (key.isRightKey && widget.onNavigateRight != null) { - widget.onNavigateRight!(); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { final style = widget.style ?? DefaultTextStyle.of(context).style; @@ -123,31 +95,22 @@ class _CollapsibleTextState extends State { ), ); - final focusNode = widget.focusNode; - if (focusNode != null) { - result = Focus( - focusNode: focusNode, - skipTraversal: widget.skipTraversal, - onKeyEvent: _handleKeyEvent, - child: ListenableBuilder( - listenable: focusNode, - builder: (context, child) { - final showFocus = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: showFocus - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: child, - ); - }, - child: result, - ), - ); + result = FocusableWrapper( + focusNode: widget.focusNode, + onSelect: _toggleExpanded, + onNavigateUp: widget.onNavigateUp, + onNavigateDown: widget.onNavigateDown, + onNavigateLeft: widget.onNavigateLeft, + onNavigateRight: widget.onNavigateRight, + semanticLabel: _expanded ? t.accessibility.collapseText : t.accessibility.expandText, + descendantsAreFocusable: false, + disableScale: true, + useBackgroundFocus: true, + borderRadius: 8, + child: result, + ); + if (widget.skipTraversal) { + result = ExcludeFocusTraversal(child: result); } return ClickableCursor( diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index cf7eccea..7b692941 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -9,6 +9,7 @@ import '../../services/settings_service.dart'; import '../../utils/dialogs.dart'; import '../../focus/focusable_button.dart'; import '../../focus/key_event_utils.dart'; +import '../dialog_action_button.dart'; class RemoteSessionDialog extends StatefulWidget { const RemoteSessionDialog({super.key}); @@ -115,22 +116,22 @@ class _RemoteSessionDialogState extends State with MountedS title: Text(t.common.error), content: Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')), actions: [ - FocusableButton( + DialogActionButton( autofocus: true, focusNode: _errorCloseFocusNode, onPressed: _close, onBack: _close, onNavigateRight: () => _errorRetryFocusNode.requestFocus(), useBackgroundFocus: true, - child: TextButton(onPressed: _close, child: Text(t.common.close)), + label: t.common.close, ), - FocusableButton( + DialogActionButton( focusNode: _errorRetryFocusNode, onPressed: _startServer, onBack: _close, onNavigateLeft: () => _errorCloseFocusNode.requestFocus(), useBackgroundFocus: true, - child: TextButton(onPressed: _startServer, child: Text(t.common.retry)), + label: t.common.retry, ), ], ); diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index aef6653b..a913b93b 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; import '../focus/focusable_button.dart'; +import '../focus/focusable_wrapper.dart'; import '../i18n/strings.g.dart'; import '../models/trackers/device_code.dart'; import '../utils/snackbar_helper.dart'; @@ -45,17 +46,25 @@ class DeviceCodeDialog extends StatelessWidget { Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium), const SizedBox(height: 16), Center( - child: InkWell( - onTap: () => _copy(context), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Text( - code.userCode, - style: theme.textTheme.displaySmall?.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - letterSpacing: 4, - fontWeight: .w600, + child: FocusableWrapper( + onSelect: () => _copy(context), + semanticLabel: t.services.deviceCode.copyCode, + descendantsAreFocusable: false, + useBackgroundFocus: true, + borderRadius: 8, + child: InkWell( + canRequestFocus: false, + onTap: () => _copy(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text( + code.userCode, + style: theme.textTheme.displaySmall?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + letterSpacing: 4, + fontWeight: .w600, + ), ), ), ), @@ -66,6 +75,7 @@ class DeviceCodeDialog extends StatelessWidget { width: double.infinity, child: FocusableButton( onPressed: _open, + useBackgroundFocus: true, child: FilledButton.icon( icon: const Icon(Icons.open_in_new), label: Text(t.services.deviceCode.openToActivate(service: serviceName)), diff --git a/lib/widgets/dialog_action_button.dart b/lib/widgets/dialog_action_button.dart index ec29067a..e73fedf4 100644 --- a/lib/widgets/dialog_action_button.dart +++ b/lib/widgets/dialog_action_button.dart @@ -9,40 +9,61 @@ import '../focus/focusable_button.dart'; /// `FocusableButton(onPressed: ..., child: TextButton(onPressed: ..., ...))` /// boilerplate with a single call. class DialogActionButton extends StatelessWidget { - final VoidCallback onPressed; + final VoidCallback? onPressed; final String label; final FocusNode? focusNode; + final bool autofocus; final bool isPrimary; + final bool? useBackgroundFocus; + final VoidCallback? onBack; final VoidCallback? onNavigateUp; final VoidCallback? onNavigateDown; final VoidCallback? onNavigateLeft; final VoidCallback? onNavigateRight; - + final ButtonStyle? style; + final Widget? icon; const DialogActionButton({ super.key, required this.onPressed, required this.label, this.focusNode, + this.autofocus = false, this.isPrimary = false, + this.useBackgroundFocus, + this.onBack, this.onNavigateUp, this.onNavigateDown, this.onNavigateLeft, this.onNavigateRight, + this.style, + this.icon, }); @override Widget build(BuildContext context) { + final button = switch ((isPrimary, icon)) { + (true, final Widget icon) => FilledButton.icon( + onPressed: onPressed, + style: style, + icon: icon, + label: Text(label), + ), + (true, null) => FilledButton(onPressed: onPressed, style: style, child: Text(label)), + (false, final Widget icon) => TextButton.icon(onPressed: onPressed, style: style, icon: icon, label: Text(label)), + (false, null) => TextButton(onPressed: onPressed, style: style, child: Text(label)), + }; + return FocusableButton( focusNode: focusNode, + autofocus: autofocus, onPressed: onPressed, - useBackgroundFocus: isPrimary, + useBackgroundFocus: useBackgroundFocus ?? isPrimary, + onBack: onBack, onNavigateUp: onNavigateUp, onNavigateDown: onNavigateDown, onNavigateLeft: onNavigateLeft, onNavigateRight: onNavigateRight, - child: isPrimary - ? FilledButton(onPressed: onPressed, child: Text(label)) - : TextButton(onPressed: onPressed, child: Text(label)), + child: button, ); } } diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index a6015ba5..04419d3f 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -355,3 +355,85 @@ class _FocusableSwitchListTileState extends State ); } } + +/// A CheckboxListTile that accepts a FocusNode for keyboard/controller navigation. +/// +/// Uses Flutter's native CheckboxListTile focus support - no custom styling wrapper. +class FocusableCheckboxListTile extends StatefulWidget { + final Widget? title; + final Widget? subtitle; + final Widget? secondary; + final bool? value; + final ValueChanged? onChanged; + final bool tristate; + final bool dense; + final FocusNode? focusNode; + final bool autofocus; + final VisualDensity? visualDensity; + final EdgeInsetsGeometry? contentPadding; + final ListTileControlAffinity controlAffinity; + + const FocusableCheckboxListTile({ + super.key, + this.title, + this.subtitle, + this.secondary, + required this.value, + required this.onChanged, + this.tristate = false, + this.dense = true, + this.focusNode, + this.autofocus = false, + this.visualDensity = const VisualDensity(vertical: -3), + this.contentPadding, + this.controlAffinity = ListTileControlAffinity.platform, + }); + + @override + State createState() => _FocusableCheckboxListTileState(); +} + +class _FocusableCheckboxListTileState extends State + with FocusableTileStateMixin { + @override + FocusNode? get widgetFocusNode => widget.focusNode; + + @override + void initState() { + super.initState(); + initFocusNode(); + } + + @override + void didUpdateWidget(FocusableCheckboxListTile oldWidget) { + super.didUpdateWidget(oldWidget); + updateFocusNode(oldWidget.focusNode); + } + + @override + void dispose() { + disposeFocusNode(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ClickableCursor( + enabled: widget.onChanged != null, + child: CheckboxListTile( + title: widget.title, + subtitle: widget.subtitle, + secondary: widget.secondary, + value: widget.value, + onChanged: widget.onChanged, + tristate: widget.tristate, + dense: widget.dense, + visualDensity: widget.visualDensity, + contentPadding: widget.contentPadding, + focusNode: effectiveFocusNode, + autofocus: widget.autofocus, + controlAffinity: widget.controlAffinity, + ), + ); + } +} diff --git a/lib/widgets/focusable_popup_menu_button.dart b/lib/widgets/focusable_popup_menu_button.dart index 2156a644..01716480 100644 --- a/lib/widgets/focusable_popup_menu_button.dart +++ b/lib/widgets/focusable_popup_menu_button.dart @@ -6,10 +6,17 @@ import 'app_menu.dart'; /// An [AppMenuButton] that can be focused and opened with D-pad select. class FocusablePopupMenuButton extends StatefulWidget { final Widget? icon; + final Widget? child; final String? tooltip; + final bool enabled; final AppMenuEntryBuilder itemBuilder; final ValueChanged? onSelected; final GlobalKey>? menuKey; + final AppMenuAnchorAlignment anchorAlignment; + final Offset alignmentOffset; + final double minWidth; + final double? maxWidth; + final EdgeInsetsGeometry? childPadding; final FocusNode? focusNode; final VoidCallback? onNavigateUp; final VoidCallback? onNavigateDown; @@ -23,10 +30,17 @@ class FocusablePopupMenuButton extends StatefulWidget { const FocusablePopupMenuButton({ super.key, this.icon, + this.child, this.tooltip, + this.enabled = true, required this.itemBuilder, this.onSelected, this.menuKey, + this.anchorAlignment = AppMenuAnchorAlignment.start, + this.alignmentOffset = Offset.zero, + this.minWidth = 220, + this.maxWidth, + this.childPadding, this.focusNode, this.onNavigateUp, this.onNavigateDown, @@ -36,7 +50,7 @@ class FocusablePopupMenuButton extends StatefulWidget { this.borderRadius = 100, this.useBackgroundFocus = true, this.enableLongPress = true, - }); + }) : assert(icon != null || child != null, 'FocusablePopupMenuButton requires icon or child'); @override State> createState() => _FocusablePopupMenuButtonState(); @@ -53,6 +67,7 @@ class _FocusablePopupMenuButtonState extends State extends State( key: _menuKey, icon: widget.icon, tooltip: widget.tooltip, + enabled: widget.enabled, onSelected: widget.onSelected, entriesBuilder: widget.itemBuilder, + anchorAlignment: widget.anchorAlignment, + alignmentOffset: widget.alignmentOffset, + minWidth: widget.minWidth, + maxWidth: widget.maxWidth, + childPadding: widget.childPadding, + child: widget.child, ), ); } diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index 629ddeae..d2842f73 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -26,6 +26,12 @@ class FocusedScrollScaffold extends StatefulWidget { /// Optional actions to display in the app bar (e.g., IconButton widgets). final List? actions; + /// Whether app-bar controls participate in keyboard/controller traversal. + /// + /// They remain excluded while initial focus is assigned so the first + /// content control still receives focus when the screen opens. + final bool focusableAppBarActions; + /// Whether the app bar should remain visible when scrolling. /// Defaults to true. final bool pinned; @@ -44,6 +50,7 @@ class FocusedScrollScaffold extends StatefulWidget { required this.title, required this.slivers, this.actions, + this.focusableAppBarActions = false, this.pinned = true, this.automaticallyImplyLeading = true, this.onBackPressed, @@ -56,6 +63,7 @@ class FocusedScrollScaffold extends StatefulWidget { class _FocusedScrollScaffoldState extends State { final _scopeNode = FocusScopeNode(); bool _focusRequested = false; + bool _appBarFocusEnabled = false; @override void dispose() { @@ -71,6 +79,9 @@ class _FocusedScrollScaffoldState extends State { if (_scopeNode.focusedChild != null) return; _scopeNode.requestFocus(); _scopeNode.nextFocus(); + if (widget.focusableAppBarActions && !_appBarFocusEnabled) { + setState(() => _appBarFocusEnabled = true); + } }); } @@ -94,14 +105,22 @@ class _FocusedScrollScaffoldState extends State { child: Scaffold( body: CustomScrollView( slivers: [ - ExcludeFocus( - child: CustomAppBar( + if (!widget.focusableAppBarActions || !_appBarFocusEnabled) + ExcludeFocus( + child: CustomAppBar( + title: widget.title, + pinned: widget.pinned, + actions: widget.actions, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + ), + ) + else + CustomAppBar( title: widget.title, pinned: widget.pinned, actions: widget.actions, automaticallyImplyLeading: widget.automaticallyImplyLeading, ), - ), ...widget.slivers, ], ), diff --git a/lib/widgets/hotkey_recorder.dart b/lib/widgets/hotkey_recorder.dart index e4eadc1e..5302e6e8 100644 --- a/lib/widgets/hotkey_recorder.dart +++ b/lib/widgets/hotkey_recorder.dart @@ -5,10 +5,11 @@ import '../models/hotkey_model.dart'; /// Captures a key combination from the user and calls [onHotKeyRecorded]. class HotKeyRecorder extends StatefulWidget { - const HotKeyRecorder({super.key, this.initalHotKey, required this.onHotKeyRecorded}); + const HotKeyRecorder({super.key, this.initalHotKey, required this.onHotKeyRecorded, this.enabled = true}); final HotKey? initalHotKey; final ValueChanged onHotKeyRecorded; + final bool enabled; @override State createState() => _HotKeyRecorderState(); @@ -24,6 +25,14 @@ class _HotKeyRecorderState extends State { HardwareKeyboard.instance.addHandler(_handleKeyEvent); } + @override + void didUpdateWidget(HotKeyRecorder oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initalHotKey != oldWidget.initalHotKey) { + _hotKey = widget.initalHotKey; + } + } + @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleKeyEvent); @@ -31,6 +40,7 @@ class _HotKeyRecorderState extends State { } bool _handleKeyEvent(KeyEvent keyEvent) { + if (!widget.enabled) return false; if (keyEvent is KeyUpEvent) return false; final physicalKeysPressed = HardwareKeyboard.instance.physicalKeysPressed; diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index 7c396ca5..b6e806a2 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -24,6 +24,7 @@ import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'app_menu.dart'; +import 'bottom_sheet_header.dart'; import 'overlay_sheet.dart'; /// A menu action item for context menus @@ -320,6 +321,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { List? _originalOrder; // Original order before move (for cancel) final FocusNode _listFocusNode = FocusNode(); final ScrollController _dialogScrollController = ScrollController(); + final ScrollController _sheetScrollController = ScrollController(); bool _backKeyDownSeen = false; @override @@ -332,6 +334,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { void dispose() { _listFocusNode.dispose(); _dialogScrollController.dispose(); + _sheetScrollController.dispose(); super.dispose(); } @@ -505,7 +508,11 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { for (final item in menuItems) AppMenuItem(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive), ], - onSelected: (value) => widget.onLibraryMenuAction(value, library), + closeOnSelected: false, + onSelected: (value) { + OverlaySheetController.popAdaptive(context, value); + widget.onLibraryMenuAction(value, library); + }, ), ); } @@ -547,6 +554,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ), body: Focus( focusNode: _listFocusNode, + descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), onKeyEvent: _handleKeyEvent, child: _buildFlatLibraryListDialog(hiddenLibraryKeys), @@ -556,47 +564,19 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ); } - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: Row( - children: [ - const AppIcon(Symbols.edit_rounded, fill: 1), - const SizedBox(width: 12), - Expanded( - child: Text(t.libraries.manageLibraries, style: const TextStyle(fontSize: 20, fontWeight: .bold)), - ), - IconButton( - icon: const AppIcon(Symbols.close_rounded, fill: 1), - onPressed: () => OverlaySheetController.popAdaptive(context), - ), - ], - ), - ), - - // Library list (grouped by server if multiple servers) - Expanded( - child: Focus( - focusNode: _listFocusNode, - autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, - child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys), - ), - ), - ], - ); - }, + return Column( + children: [ + BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded), + Flexible( + child: Focus( + focusNode: _listFocusNode, + descendantsAreFocusable: false, + autofocus: InputModeTracker.isKeyboardMode(context), + onKeyEvent: _handleKeyEvent, + child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys), + ), + ), + ], ); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1df0133a..09b05f8a 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -43,7 +43,7 @@ import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../utils/dialogs.dart'; import '../services/external_player_service.dart'; -import '../focus/focusable_button.dart'; +import 'dialog_action_button.dart'; import '../focus/focusable_text_field.dart'; import '../focus/key_event_utils.dart'; import '../screens/plex_match_screen.dart'; @@ -588,6 +588,7 @@ class MediaContextMenuState extends State { selected = await OverlaySheetController.showAdaptive( this.context, showDragHandle: true, + isScrollControlled: true, builder: (context) => AppMenuSheet( title: _itemDisplayTitle(), entries: _menuEntries(menuActions), @@ -1320,6 +1321,7 @@ class MediaContextMenuState extends State { await OverlaySheetController.showAdaptive( this.context, showDragHandle: true, + isScrollControlled: true, builder: (context) => RatingBottomSheet( item: item, serverClient: client, @@ -1959,12 +1961,7 @@ class _PickerDialogScaffoldState extends State<_PickerDialogScaffold> { ], ), ), - actions: [ - FocusableButton( - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - ], + actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)], ), ); } diff --git a/lib/widgets/music/mini_player.dart b/lib/widgets/music/mini_player.dart index cd7a59ed..9258d4ae 100644 --- a/lib/widgets/music/mini_player.dart +++ b/lib/widgets/music/mini_player.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_action_bar.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../media/ids.dart'; import '../../media/media_item.dart'; @@ -16,7 +18,6 @@ import '../../utils/music_navigation.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/video_player_navigation.dart'; -import '../app_icon.dart'; import '../media_context_menu.dart'; import '../optimized_media_image.dart'; import '../overlay_sheet.dart'; @@ -252,7 +253,14 @@ class _MiniPlayerCard extends StatefulWidget { } class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMixin<_MiniPlayerCard> { - bool _hovered = false; + final _detailsFocusNode = FocusNode(debugLabel: 'mini_player_details'); + final _transportKey = GlobalKey(); + + @override + void dispose() { + _detailsFocusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -266,96 +274,111 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix color: tk.surface, clipBehavior: Clip.antiAlias, borderRadius: BorderRadius.circular(tk.radiusLg), - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: () => unawaited(openNowPlaying(context)), - onTapDown: storeTapPosition, - onLongPress: showContextMenuFromTap, - onSecondaryTapDown: storeTapPosition, - onSecondaryTap: showContextMenuFromTap, - child: SizedBox( - height: _MusicMiniPlayerOverlayState._cardHeight, - child: Stack( - children: [ - // Played fraction tints the card background itself — the card - // fills up as the track progresses (clipped by the Material's - // rounded corners above). - const Positioned.fill(child: _MiniPlayerProgress()), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(tk.radiusSm), - child: OptimizedMediaImage( - client: client, - imagePath: widget.track.thumbPath, - imageType: ImageType.square, - width: 48, - height: 48, - fallbackIcon: Symbols.music_note_rounded, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - mainAxisAlignment: .center, - crossAxisAlignment: .start, - children: [ - Text( - widget.track.title ?? '', - maxLines: 1, - overflow: .ellipsis, - style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text), - ), - if (artist != null && artist.isNotEmpty) - Text( - artist, - maxLines: 1, - overflow: .ellipsis, - style: TextStyle(fontSize: 12, color: tk.textMuted), + child: SizedBox( + height: _MusicMiniPlayerOverlayState._cardHeight, + child: Stack( + children: [ + const Positioned.fill(child: _MiniPlayerProgress()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: FocusableWrapper( + focusNode: _detailsFocusNode, + onSelect: () => unawaited(openNowPlaying(context)), + enableLongPress: true, + onLongPress: showContextMenuFromTap, + onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(), + semanticLabel: widget.track.title, + descendantsAreFocusable: false, + disableScale: true, + useBackgroundFocus: true, + borderRadius: tk.radiusLg, + child: InkWell( + canRequestFocus: false, + mouseCursor: SystemMouseCursors.click, + onTap: () => unawaited(openNowPlaying(context)), + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(tk.radiusSm), + child: OptimizedMediaImage( + client: client, + imagePath: widget.track.thumbPath, + imageType: ImageType.square, + width: 48, + height: 48, + fallbackIcon: Symbols.music_note_rounded, + ), ), - ], - ), - ), - if (widget.desktop) - IconButton( - icon: AppIcon(Symbols.skip_previous_rounded, fill: 1, color: tk.text), - tooltip: t.music.previousTrack, - onPressed: () => unawaited(service.previous()), - ), - IconButton( - icon: AppIcon( - isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, - fill: 1, - color: tk.text, - ), - tooltip: isPlaying ? t.common.pause : t.common.play, - onPressed: () => unawaited(service.togglePlayPause()), - ), - IconButton( - icon: AppIcon(Symbols.skip_next_rounded, fill: 1, color: tk.text), - tooltip: t.music.nextTrack, - onPressed: () => unawaited(service.next()), - ), - if (widget.desktop) - AnimatedOpacity( - opacity: _hovered ? 1 : 0, - duration: tk.fast, - child: IgnorePointer( - ignoring: !_hovered, - child: IconButton( - icon: AppIcon(Symbols.close_rounded, fill: 1, size: 20, color: tk.textMuted), - tooltip: t.music.stopPlayback, - onPressed: widget.onDismissed, - ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [ + Text( + widget.track.title ?? '', + maxLines: 1, + overflow: .ellipsis, + style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text), + ), + if (artist != null && artist.isNotEmpty) + Text( + artist, + maxLines: 1, + overflow: .ellipsis, + style: TextStyle(fontSize: 12, color: tk.textMuted), + ), + ], + ), + ), + ], ), ), - ], - ), + ), + ), + FocusableActionBar( + key: _transportKey, + onNavigateLeft: _detailsFocusNode.requestFocus, + actions: [ + if (widget.desktop) + FocusableAction( + icon: Symbols.skip_previous_rounded, + iconColor: tk.text, + tooltip: t.music.previousTrack, + onPressed: () => unawaited(service.previous()), + ), + FocusableAction( + icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, + iconColor: tk.text, + tooltip: isPlaying ? t.common.pause : t.common.play, + onPressed: () => unawaited(service.togglePlayPause()), + ), + FocusableAction( + icon: Symbols.skip_next_rounded, + iconColor: tk.text, + tooltip: t.music.nextTrack, + onPressed: () => unawaited(service.next()), + ), + if (widget.desktop) + FocusableAction( + icon: Symbols.close_rounded, + iconColor: tk.textMuted, + tooltip: t.music.stopPlayback, + onPressed: widget.onDismissed, + ), + ], + ), + ], ), - ], - ), + ), + ], ), ), ); @@ -377,12 +400,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix child: card, ); - if (!widget.desktop) return card; - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: card, - ); + return card; } } diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index 06f8f62b..8d463d0c 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../i18n/strings.g.dart'; import '../focus/focusable_button.dart'; +import '../focus/focusable_wrapper.dart'; import '../services/trackers/oauth_proxy_client.dart'; import '../utils/snackbar_helper.dart'; import 'dialog_action_button.dart'; @@ -55,18 +56,26 @@ class OAuthProxyDialog extends StatelessWidget { ), ), const SizedBox(height: 16), - InkWell( - onTap: () => _copyUrl(context), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Text( - start.url, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme.colorScheme.onSurfaceVariant, + FocusableWrapper( + onSelect: () => _copyUrl(context), + semanticLabel: t.services.oauthProxy.copyUrl, + descendantsAreFocusable: false, + borderRadius: 8, + useBackgroundFocus: true, + child: InkWell( + canRequestFocus: false, + onTap: () => _copyUrl(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + start.url, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ), ), @@ -75,6 +84,7 @@ class OAuthProxyDialog extends StatelessWidget { width: double.infinity, child: FocusableButton( onPressed: _open, + useBackgroundFocus: true, child: FilledButton.icon( icon: const Icon(Icons.open_in_new), label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)), diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 2d4156da..3c67ef53 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -156,20 +156,41 @@ class OverlaySheetController { /// Push a sub-page using the overlay system if available, otherwise fall /// back to [showModalBottomSheet]. Returns the result from the page. + /// + /// Presentation options apply only to the modal fallback. A hosted push + /// retains the root sheet's presentation and changes only its page content. static Future pushAdaptive( BuildContext context, { required WidgetBuilder builder, FocusNode? initialFocusNode, + BoxConstraints? constraints, + Color? backgroundColor, + bool barrierDismissible = true, + bool isScrollControlled = false, + bool showDragHandle = false, }) async { final controller = maybeOf(context); if (controller != null) { return controller.push(builder: builder, initialFocusNode: initialFocusNode); } + final effectiveConstraints = + constraints ?? + () { + final size = MediaQuery.sizeOf(context); + final isDesktop = size.width > 600; + return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); + }(); + BackKeyCoordinator.clear(); openSheetCount.value++; try { return await showModalBottomSheet( context: context, builder: (context) => SafeArea(top: false, child: builder(context)), + constraints: effectiveConstraints, + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface, + isDismissible: barrierDismissible, + isScrollControlled: isScrollControlled, + showDragHandle: showDragHandle, ); } finally { openSheetCount.value--; @@ -310,6 +331,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr Alignment alignment = Alignment.bottomCenter, bool showDragHandle = false, }) { + BackKeyCoordinator.clear(); // If already open, close first (instant) final wasOpen = _isOpen; if (_isOpen) { diff --git a/lib/widgets/seerr_request_sheet.dart b/lib/widgets/seerr_request_sheet.dart index de31be5d..d2fc6185 100644 --- a/lib/widgets/seerr_request_sheet.dart +++ b/lib/widgets/seerr_request_sheet.dart @@ -21,6 +21,7 @@ import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'app_menu.dart'; import 'loading_indicator_box.dart'; +import 'focusable_list_tile.dart'; import 'overlay_sheet.dart'; import 'stat_chip.dart'; @@ -396,7 +397,7 @@ class _SeerrRequestSheetState extends State { else ...[ if (!_isMovie && _partialSeasons) ..._buildSeasonSection(theme), if (_can4k) - SwitchListTile( + FocusableSwitchListTile( value: _is4k, onChanged: _submitting ? null : _toggle4k, title: Text(t.seerr.request4k), @@ -432,7 +433,11 @@ class _SeerrRequestSheetState extends State { children: [ Text(t.seerr.requestsLoadFailed, style: theme.textTheme.bodyMedium), const SizedBox(height: 12), - OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)), + FocusableButton( + autofocus: true, + onPressed: () => unawaited(_load()), + child: OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)), + ), ], ), ); @@ -480,7 +485,7 @@ class _SeerrRequestSheetState extends State { final number = season.seasonNumber; final blockedLabel = _seasonBlockedLabel(number); final episodeCount = season.episodeCount; - return CheckboxListTile( + return FocusableCheckboxListTile( focusNode: _seasonFocusNodes[index], value: blockedLabel != null || _selectedSeasons.contains(number), onChanged: blockedLabel != null || _submitting @@ -604,7 +609,7 @@ class _PickerTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( + return FocusableListTile( leading: AppIcon(icon, fill: 1), title: Text(label), subtitle: value.isEmpty ? null : Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart index 1339aa7a..ad0a232e 100644 --- a/lib/widgets/setting_tile.dart +++ b/lib/widgets/setting_tile.dart @@ -6,7 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../screens/settings/settings_utils.dart'; import '../services/settings_service.dart'; import 'app_icon.dart'; -import 'clickable_cursor.dart'; +import 'focusable_list_tile.dart'; import 'settings_section.dart'; /// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable]. @@ -43,22 +43,21 @@ class SettingSwitchTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, value, _) => ClickableCursor( - enabled: enabled, - child: SwitchListTile( - focusNode: focusNode, - secondary: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - value: value, - onChanged: enabled - ? (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - } - : null, - ), + builder: (_, value, _) => FocusableSwitchListTile( + focusNode: focusNode, + secondary: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + value: value, + dense: false, + visualDensity: VisualDensity.standard, + onChanged: enabled + ? (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + } + : null, ), ); } @@ -87,15 +86,15 @@ class SettingNavigationTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ClickableCursor( - child: ListTile( - focusNode: focusNode, - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: AppIcon(trailingIcon, fill: 1), - onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), - ), + return FocusableListTile( + focusNode: focusNode, + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: AppIcon(trailingIcon, fill: 1), + onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), + dense: false, + visualDensity: VisualDensity.standard, ); } } @@ -130,26 +129,26 @@ class SettingNumberTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, value, _) => ClickableCursor( - child: ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showNumericInputDialog( - context: context, - title: title, - labelText: labelText, - suffixText: suffixText, - min: min, - max: max, - currentValue: value, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, - ), + builder: (_, value, _) => FocusableListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitleBuilder(value)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + dense: false, + visualDensity: VisualDensity.standard, + onTap: () => showNumericInputDialog( + context: context, + title: title, + labelText: labelText, + suffixText: suffixText, + min: min, + max: max, + currentValue: value, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, ), ), ); @@ -188,25 +187,25 @@ class SettingSelectionTile extends StatelessWidget { valueListenable: svc.listenable(pref), builder: (_, raw, _) { final value = decode(raw); - return ClickableCursor( - child: ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () async { - final picked = await showSelectionDialog( - context: context, - title: title, - options: options, - currentValue: value, - ); - if (picked == null) return; - await svc.write(pref, encode(picked)); - final callback = onAfterWrite; - if (callback != null) await callback(picked); - }, - ), + return FocusableListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitleBuilder(value)), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + dense: false, + visualDensity: VisualDensity.standard, + onTap: () async { + final picked = await showSelectionDialog( + context: context, + title: title, + options: options, + currentValue: value, + ); + if (picked == null) return; + await svc.write(pref, encode(picked)); + final callback = onAfterWrite; + if (callback != null) await callback(picked); + }, ); }, ); @@ -237,23 +236,23 @@ class SettingRegexTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, value, _) => ClickableCursor( - child: ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: Text(subtitle), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showRegexInputDialog( - context: context, - title: title, - currentValue: value, - defaultValue: defaultValue, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, - ), + builder: (_, value, _) => FocusableListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: Text(subtitle), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + dense: false, + visualDensity: VisualDensity.standard, + onTap: () => showRegexInputDialog( + context: context, + title: title, + currentValue: value, + defaultValue: defaultValue, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, ), ), ); @@ -329,31 +328,31 @@ class SettingColorTile extends StatelessWidget { final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), - builder: (_, hex, _) => ClickableCursor( - child: ListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: hexToColor(hex), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), - ), - ), - onTap: () => showColorInputDialog( - context: context, - title: title, - currentHex: hex, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + builder: (_, hex, _) => FocusableListTile( + leading: AppIcon(icon, fill: 1), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: hexToColor(hex), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), ), + dense: false, + visualDensity: VisualDensity.standard, + onTap: () => showColorInputDialog( + context: context, + title: title, + currentHex: hex, + onSave: (v) async { + await svc.write(pref, v); + final callback = onAfterWrite; + if (callback != null) await callback(v); + }, + ), ), ); } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 35fb9c51..53b96d54 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_memory_tracker.dart'; +import '../focus/input_mode_tracker.dart'; import '../media/media_item.dart'; import '../media/media_library.dart'; import '../mixins/mounted_set_state_mixin.dart'; @@ -103,7 +104,7 @@ class NavigationRailItem extends StatelessWidget { return ListenableBuilder( listenable: focusNode, builder: (context, _) { - final focused = focusNode.hasFocus; + final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context); return Focus( focusNode: focusNode, autofocus: autofocus, @@ -1014,9 +1015,10 @@ class SideNavigationRailState extends State with MountedSetS child: Container( decoration: BoxDecoration( color: () { - if (isCollapsed) return librariesFocusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null; + final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); + if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null; if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1); - if (librariesFocusNode.hasFocus) return t.text.withValues(alpha: 0.08); + if (showFocus) return t.text.withValues(alpha: 0.08); return null; }(), borderRadius: BorderRadius.circular(tokens(context).radiusMd), @@ -1240,7 +1242,9 @@ class SideNavigationRailState extends State with MountedSetS borderRadius: radius, child: Container( decoration: BoxDecoration( - color: focusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null, + color: focusNode.hasFocus && InputModeTracker.isKeyboardMode(context) + ? t.text.withValues(alpha: 0.08) + : null, borderRadius: radius, ), clipBehavior: Clip.hardEdge, diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index 766233d7..84846f1c 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -300,6 +300,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper return Focus( focusNode: _focusNode, autofocus: widget.autofocus, + descendantsAreFocusable: false, onFocusChange: (hasFocus) { setState(() => _isFocused = hasFocus); if (!hasFocus) stopRepeat(); diff --git a/lib/widgets/tv_number_spinner.dart b/lib/widgets/tv_number_spinner.dart index 6b1d135c..3b6b0b9b 100644 --- a/lib/widgets/tv_number_spinner.dart +++ b/lib/widgets/tv_number_spinner.dart @@ -130,6 +130,7 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< return Focus( focusNode: _focusNode, autofocus: widget.autofocus, + descendantsAreFocusable: false, onFocusChange: (hasFocus) { setState(() => _isFocused = hasFocus); if (!hasFocus) stopRepeat(); diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index f66b5644..c8533df1 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -693,6 +693,8 @@ class DesktopVideoControlsState extends State { metadata: widget.metadata, style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine, onBack: widget.onBack, + onCancelAutoHide: widget.onCancelAutoHide, + onStartAutoHide: widget.onStartAutoHide, ), ), if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[ diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index bb12d48e..071af3ab 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -344,6 +344,8 @@ class _MobileVideoControlsState extends State with SingleTi child: VideoControlsHeader( metadata: widget.metadata, style: VideoHeaderStyle.multiLine, + onCancelAutoHide: widget.onCancelAutoHide, + onStartAutoHide: widget.onStartAutoHide, trailing: widget.trackChapterControls, onBack: widget.onBack, ), diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index 1b4b9bf2..0838fdaf 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -139,6 +139,13 @@ class _SubtitleSearchSheetState extends State with Controll OverlaySheetController.of(context).refocus(); } + void _hideLanguagePickerView() { + setState(() => _showLanguagePicker = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _languageFocusNode.requestFocus(); + }); + } + void _focusFirstResult() { if (_results != null && _results!.isNotEmpty && !_isSearching && _error == null) { _firstResultFocusNode.requestFocus(); @@ -219,7 +226,7 @@ class _SubtitleSearchSheetState extends State with Controll return _LanguagePickerView( currentCode: _languageCode, onSelected: _onLanguageSelected, - onBack: () => setState(() => _showLanguagePicker = false), + onBack: _hideLanguagePickerView, ); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 622230d9..5cf77a5d 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -268,8 +268,8 @@ class _VideoSettingsSheetState extends State { final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay'; final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset; - // Created here so we can pass it as initialFocusNode to the overlay sheet, - // ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar. + // Created here so it can be passed as the overlay's initial focus target. + // The creator disposes it after the overlay's lifecycle completes. final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider'); // show() with new alignment replaces the current sheet (completing the @@ -299,6 +299,7 @@ class _VideoSettingsSheetState extends State { ), ) .whenComplete(() { + sliderFocusNode.dispose(); widget.onStartAutoHide?.call(); }); @@ -1073,7 +1074,6 @@ class _CompactSyncBarState extends State<_CompactSyncBar> { @override void dispose() { - widget.sliderFocusNode.dispose(); _resetFocusNode.dispose(); _closeFocusNode.dispose(); super.dispose(); 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 01e2dd48..78c7b690 100644 --- a/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart +++ b/lib/widgets/video_controls/widgets/sleep_timer_active_status.dart @@ -70,6 +70,7 @@ class SleepTimerActiveStatus extends StatelessWidget { sleepTimer.cancelTimer(); onCancel?.call(); }, + useBackgroundFocus: true, child: FilledButton.icon( icon: const AppIcon(Symbols.cancel_rounded, fill: 1), label: Text(t.common.cancel), diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index bd96980d..4e659717 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -1,11 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../focus/dpad_navigator.dart'; +import '../../../focus/focusable_slider.dart'; import '../../../focus/focusable_button.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../mpv/mpv.dart'; @@ -233,35 +232,19 @@ class _SyncOffsetControlState extends State { onLongPressStart: _startLongPressDecrement, ), Expanded( - child: Focus( - onKeyEvent: (node, event) { - // Select/enter on the slider jumps focus to the close button - if (event.logicalKey.isSelectKey && event is KeyDownEvent) { - widget.closeFocusNode?.requestFocus(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - canRequestFocus: false, - child: SliderTheme( - data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark), - child: Slider( - focusNode: widget.sliderFocusNode, - value: sliderValue, - min: _sliderMin, - max: _sliderMax, - divisions: _sliderDivisions, - activeColor: Theme.of(context).colorScheme.primary, - inactiveColor: Theme.of(context).colorScheme.outlineVariant, - onChanged: (value) { - setState(() { - _currentOffset = value; - }); - }, - onChangeEnd: (value) { - _applyOffset(value); - }, - ), + child: SliderTheme( + data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark), + child: FocusableSlider( + focusNode: widget.sliderFocusNode, + value: sliderValue, + min: _sliderMin, + max: _sliderMax, + divisions: _sliderDivisions, + activeColor: Theme.of(context).colorScheme.primary, + inactiveColor: Theme.of(context).colorScheme.outlineVariant, + onSelect: widget.closeFocusNode?.requestFocus, + onChanged: (value) => setState(() => _currentOffset = value), + onChangeEnd: _applyOffset, ), ), ), @@ -373,6 +356,7 @@ class _SyncOffsetControlState extends State { // Reset button FocusableButton( onPressed: _currentOffset != 0 ? _resetOffset : null, + useBackgroundFocus: true, child: ElevatedButton.icon( onPressed: _currentOffset != 0 ? _resetOffset : null, icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1), diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index e6d4e2e8..545c0a93 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -30,6 +30,8 @@ class VideoControlsHeader extends StatelessWidget { /// Optional callback for back button. If null, defaults to Navigator.pop(true). final VoidCallback? onBack; + final VoidCallback? onCancelAutoHide; + final VoidCallback? onStartAutoHide; const VideoControlsHeader({ super.key, @@ -37,6 +39,8 @@ class VideoControlsHeader extends StatelessWidget { this.style = VideoHeaderStyle.multiLine, this.trailing, this.onBack, + this.onCancelAutoHide, + this.onStartAutoHide, }); @override @@ -54,7 +58,13 @@ class VideoControlsHeader extends StatelessWidget { selector: (_, p) => p.isInSession, builder: (context, inSession, child) { if (!inSession) return const SizedBox.shrink(); - return const Padding(padding: .only(right: 8), child: WatchTogetherSessionIndicator()); + return Padding( + padding: const EdgeInsets.only(right: 8), + child: WatchTogetherSessionIndicator( + onCancelAutoHide: onCancelAutoHide, + onStartAutoHide: onStartAutoHide, + ), + ); }, ), ?trailing, diff --git a/test/focus/focusable_slider_test.dart b/test/focus/focusable_slider_test.dart new file mode 100644 index 00000000..211fd28e --- /dev/null +++ b/test/focus/focusable_slider_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_slider.dart'; + +void main() { + testWidgets('D-pad adjustment reports a complete persisted change', (tester) async { + final focusNode = FocusNode(debugLabel: 'slider'); + addTearDown(focusNode.dispose); + final starts = []; + final changes = []; + final ends = []; + var value = 0.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => FocusableSlider( + focusNode: focusNode, + value: value, + min: 0, + max: 10, + divisions: 10, + onChangeStart: starts.add, + onChanged: (next) { + changes.add(next); + setState(() => value = next); + }, + onChangeEnd: ends.add, + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(starts, [0.0]); + expect(changes, [1.0]); + expect(ends, [1.0]); + expect(value, 1.0); + }); + + testWidgets('SELECT invokes the slider action once', (tester) async { + final focusNode = FocusNode(debugLabel: 'slider'); + addTearDown(focusNode.dispose); + var selected = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableSlider(focusNode: focusNode, value: 0, onChanged: (_) {}, onSelect: () => selected++), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + + expect(selected, 1); + }); +} diff --git a/test/focus/key_event_utils_test.dart b/test/focus/key_event_utils_test.dart index 153bc531..4e8b56d4 100644 --- a/test/focus/key_event_utils_test.dart +++ b/test/focus/key_event_utils_test.dart @@ -12,6 +12,7 @@ void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); BackKeyUpSuppressor.clearSuppression(); + BackKeyCoordinator.clear(); }); testWidgets('tvOS physical keyboard back runs on key down and suppresses key up', (tester) async { @@ -74,6 +75,31 @@ void main() { await tester.pump(); }); + group('BackKeyCoordinator', () { + testWidgets('suppresses one parallel back dispatch in the current frame', (tester) async { + BackKeyCoordinator.markHandled(); + + expect(BackKeyCoordinator.consumeIfHandled(), isTrue); + expect(BackKeyCoordinator.consumeIfHandled(), isFalse); + await tester.pump(); + }); + + testWidgets('does not suppress an independent system back in a later frame', (tester) async { + BackKeyCoordinator.markHandled(); + await tester.pump(); + + expect(BackKeyCoordinator.consumeIfHandled(), isFalse); + }); + + testWidgets('clear discards a pending duplicate marker', (tester) async { + BackKeyCoordinator.markHandled(); + BackKeyCoordinator.clear(); + + expect(BackKeyCoordinator.consumeIfHandled(), isFalse); + await tester.pump(); + }); + }); + group('dpadKeyHandler trapHorizontalEdges', () { testWidgets('consumes edge LEFT/RIGHT so focus cannot escape the group', (tester) async { final trapped = FocusNode(debugLabel: 'trapped'); diff --git a/test/widgets/dialog_action_button_test.dart b/test/widgets/dialog_action_button_test.dart new file mode 100644 index 00000000..dc09c2b7 --- /dev/null +++ b/test/widgets/dialog_action_button_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/dialog_action_button.dart'; + +void main() { + testWidgets('autofocus and back routing are forwarded to the focus wrapper', (tester) async { + final focusNode = FocusNode(debugLabel: 'dialog action'); + addTearDown(focusNode.dispose); + var backed = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DialogActionButton( + focusNode: focusNode, + autofocus: true, + onPressed: () {}, + onBack: () => backed++, + label: 'Save', + ), + ), + ), + ); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect(backed, 1); + }); + + testWidgets('nullable callback keeps its graph position while disabling activation', (tester) async { + final focusNode = FocusNode(debugLabel: 'disabled dialog action'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DialogActionButton( + focusNode: focusNode, + autofocus: true, + onPressed: null, + label: 'Unavailable', + isPrimary: true, + ), + ), + ), + ); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + expect(tester.widget(find.byType(FilledButton)).onPressed, isNull); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + expect(focusNode.hasFocus, isTrue); + }); +} diff --git a/test/widgets/focusable_list_tile_test.dart b/test/widgets/focusable_list_tile_test.dart new file mode 100644 index 00000000..54f06eb7 --- /dev/null +++ b/test/widgets/focusable_list_tile_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/focusable_list_tile.dart'; + +void main() { + testWidgets('switch tile toggles once from SELECT', (tester) async { + final focusNode = FocusNode(debugLabel: 'switch'); + addTearDown(focusNode.dispose); + var value = false; + var changes = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => FocusableSwitchListTile( + focusNode: focusNode, + value: value, + title: const Text('Switch'), + onChanged: (next) { + changes++; + setState(() => value = next); + }, + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(value, isTrue); + expect(changes, 1); + }); + + testWidgets('checkbox tile toggles once from SELECT', (tester) async { + final focusNode = FocusNode(debugLabel: 'checkbox'); + addTearDown(focusNode.dispose); + var value = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => FocusableCheckboxListTile( + focusNode: focusNode, + value: value, + title: const Text('Checkbox'), + onChanged: (next) => setState(() => value = next ?? false), + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(value, isTrue); + }); + + testWidgets('disabled switch tile cannot be focused or activated', (tester) async { + final focusNode = FocusNode(debugLabel: 'disabled switch'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableSwitchListTile( + focusNode: focusNode, + value: false, + title: const Text('Disabled'), + onChanged: null, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + + expect(focusNode.hasFocus, isFalse); + }); +} diff --git a/test/widgets/overlay_sheet_test.dart b/test/widgets/overlay_sheet_test.dart index 27d6c3d0..8267da4c 100644 --- a/test/widgets/overlay_sheet_test.dart +++ b/test/widgets/overlay_sheet_test.dart @@ -240,5 +240,21 @@ void main() { await tester.pumpAndSettle(); expect(find.text('SHEET'), findsNothing); }); + + testWidgets('system back in a later frame is not mistaken for a duplicate TV key', (tester) async { + var backs = 0; + await pushHost(tester, canPop: false, onSystemBack: () => backs++); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + BackKeyCoordinator.markHandled(); + await tester.pump(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('SHEET'), findsNothing); + expect(find.text('Open'), findsOneWidget); + expect(backs, 0); + }); }); } diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index eec30391..4fe8602a 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; @@ -524,20 +525,22 @@ void main() { var parentBuilds = 0; await tester.pumpWidget( - MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), - home: Scaffold( - body: Builder( - builder: (context) { - parentBuilds++; - return NavigationRailItem( - icon: Symbols.home_rounded, - label: const Text('Home'), - isSelected: false, - onTap: () {}, - focusNode: focusNode, - ); - }, + InputModeTracker( + child: MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: Builder( + builder: (context) { + parentBuilds++; + return NavigationRailItem( + icon: Symbols.home_rounded, + label: const Text('Home'), + isSelected: false, + onTap: () {}, + focusNode: focusNode, + ); + }, + ), ), ), ), @@ -547,6 +550,9 @@ void main() { expect(_railItemDecoration(tester, item)?.color, isNull); expect(parentBuilds, 1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + focusNode.requestFocus(); await tester.pump();