diff --git a/lib/focus/focus_memory_tracker.dart b/lib/focus/focus_memory_tracker.dart new file mode 100644 index 00000000..c04bb938 --- /dev/null +++ b/lib/focus/focus_memory_tracker.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +/// Reusable focus memory tracker for screens with multiple focusable items. +/// Tracks which item was last focused and provides focus restoration. +class FocusMemoryTracker { + final Map _nodes = {}; + final Set _focused = {}; + final VoidCallback? _onFocusChanged; + final String _debugLabelPrefix; + String? _lastFocusedKey; + + FocusMemoryTracker({VoidCallback? onFocusChanged, String debugLabelPrefix = 'focus'}) + : _onFocusChanged = onFocusChanged, + _debugLabelPrefix = debugLabelPrefix; + + /// Get or create a focus node for the given key + FocusNode get(String key, {String? debugLabel}) { + return _nodes.putIfAbsent(key, () { + final node = FocusNode(debugLabel: debugLabel ?? '${_debugLabelPrefix}_$key'); + node.addListener(() { + final wasFocused = _focused.contains(key); + if (node.hasFocus && !wasFocused) { + _focused.add(key); + _lastFocusedKey = key; + _onFocusChanged?.call(); + } else if (!node.hasFocus && wasFocused) { + _focused.remove(key); + _onFocusChanged?.call(); + } + }); + return node; + }); + } + + /// Get the last focused key (for focus restoration) + String? get lastFocusedKey => _lastFocusedKey; + + /// Check if a key is currently focused + bool isFocused(String key) => _focused.contains(key); + + /// Get a node without creating it (returns null if not found) + FocusNode? nodeFor(String key) => _nodes[key]; + + /// Restore focus to the last focused item, or fallback if provided + /// Returns true if focus was successfully restored + bool restoreFocus({String? fallbackKey}) { + // Try to restore last focused item + if (_lastFocusedKey != null) { + final node = _nodes[_lastFocusedKey]; + if (node != null) { + node.requestFocus(); + return true; + } + } + // Fallback: focus the provided key if available + if (fallbackKey != null) { + final node = _nodes[fallbackKey]; + if (node != null) { + node.requestFocus(); + return true; + } + } + return false; + } + + /// Remove nodes not in the given set of valid keys (prunes stale nodes) + void pruneExcept(Set validKeys) { + final toRemove = _nodes.keys.where((k) => !validKeys.contains(k)).toList(); + for (final key in toRemove) { + _nodes[key]?.dispose(); + _nodes.remove(key); + _focused.remove(key); + } + // Clear last focused if it was pruned + if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) { + _lastFocusedKey = null; + } + } + + /// Dispose all nodes + void dispose() { + for (final node in _nodes.values) { + node.dispose(); + } + _nodes.clear(); + _focused.clear(); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 26acb388..0a31e04f 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -399,13 +399,22 @@ class _MainScreenState extends State with RouteAware, WindowListener setState(() => _isSidebarFocused = false); _contentFocusScope.requestFocus(); // When content regains focus while on Libraries, retry focusing the active tab - if (_currentIndex == 1) { + if (_currentIndex == 1 && !_isOffline) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_librariesKey.currentState case final FocusableTab focusable) { focusable.focusActiveTabIfReady(); } }); } + // When content regains focus while on Settings, restore focus to last focused setting + final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline); + if (_currentIndex == settingsIndex) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_settingsKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); + } + }); + } } KeyEventResult _handleBackKey(KeyEvent event) { @@ -521,23 +530,33 @@ class _MainScreenState extends State with RouteAware, WindowListener } }); - // Skip screen-specific logic in offline mode (only Downloads and Settings available) - if (_isOffline) return; + // Handle screen-specific logic + final settingsIndex = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline); - // Notify discover screen when it becomes visible via tab switch - if (index == 0) { - _onDiscoverBecameVisible(); - } - // Ensure the libraries screen applies focus when brought into view - if (index == 1 && previousIndex != 1) { - if (_librariesKey.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); + // Skip online-only screen logic in offline mode + if (!_isOffline) { + // Notify discover screen when it becomes visible via tab switch + if (index == 0) { + _onDiscoverBecameVisible(); + } + // Ensure the libraries screen applies focus when brought into view + if (index == 1 && previousIndex != 1) { + if (_librariesKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); + } + } + // Focus search input when selecting Search tab + if (index == 2) { + if (_searchKey.currentState case final SearchInputFocusable searchable) { + searchable.focusSearchInput(); + } } } - // Focus search input when selecting Search tab - if (index == 2) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.focusSearchInput(); + + // Restore focus when switching to Settings tab (works in both online and offline mode) + if (index == settingsIndex && previousIndex != settingsIndex) { + if (_settingsKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); } } } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 1e2157e8..d795108b 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -8,7 +8,9 @@ import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../focus/focus_memory_tracker.dart'; import '../../i18n/strings.g.dart'; +import '../../mixins/refreshable.dart'; import '../../services/discord_rpc_service.dart'; import '../../services/download_storage_service.dart'; import '../../services/saf_storage_service.dart'; @@ -43,8 +45,46 @@ class SettingsScreen extends StatefulWidget { State createState() => _SettingsScreenState(); } -class _SettingsScreenState extends State { +class _SettingsScreenState extends State with FocusableTab { late settings.SettingsService _settingsService; + late final FocusMemoryTracker _focusTracker; + + // Setting keys for focus tracking + static const _kTheme = 'theme'; + static const _kLanguage = 'language'; + static const _kLibraryDensity = 'library_density'; + static const _kViewMode = 'view_mode'; + static const _kEpisodePosterMode = 'episode_poster_mode'; + static const _kShowHeroSection = 'show_hero_section'; + static const _kUseGlobalHubs = 'use_global_hubs'; + static const _kShowServerNameOnHubs = 'show_server_name_on_hubs'; + static const _kAlwaysKeepSidebarOpen = 'always_keep_sidebar_open'; + static const _kPlayerBackend = 'player_backend'; + static const _kHardwareDecoding = 'hardware_decoding'; + static const _kMatchContentFrameRate = 'match_content_frame_rate'; + static const _kBufferSize = 'buffer_size'; + static const _kSubtitleStyling = 'subtitle_styling'; + static const _kMpvConfig = 'mpv_config'; + static const _kSmallSkipDuration = 'small_skip_duration'; + static const _kLargeSkipDuration = 'large_skip_duration'; + static const _kDefaultSleepTimer = 'default_sleep_timer'; + static const _kMaxVolume = 'max_volume'; + static const _kDiscordRichPresence = 'discord_rich_presence'; + static const _kRememberTrackSelections = 'remember_track_selections'; + static const _kClickVideoTogglesPlayback = 'click_video_toggles_playback'; + static const _kAutoSkipIntro = 'auto_skip_intro'; + static const _kAutoSkipCredits = 'auto_skip_credits'; + static const _kAutoSkipDelay = 'auto_skip_delay'; + static const _kDownloadLocation = 'download_location'; + static const _kDownloadOnWifiOnly = 'download_on_wifi_only'; + static const _kVideoPlayerControls = 'video_player_controls'; + static const _kVideoPlayerNavigation = 'video_player_navigation'; + static const _kDebugLogging = 'debug_logging'; + static const _kViewLogs = 'view_logs'; + static const _kClearCache = 'clear_cache'; + static const _kResetSettings = 'reset_settings'; + static const _kCheckForUpdates = 'check_for_updates'; + static const _kAbout = 'about'; KeyboardShortcutsService? _keyboardService; late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported(); bool _isLoading = true; @@ -74,9 +114,26 @@ class _SettingsScreenState extends State { @override void initState() { super.initState(); + _focusTracker = FocusMemoryTracker( + onFocusChanged: () { + if (mounted) setState(() {}); + }, + debugLabelPrefix: 'settings', + ); _loadSettings(); } + @override + void dispose() { + _focusTracker.dispose(); + super.dispose(); + } + + @override + void focusActiveTabIfReady() { + _focusTracker.restoreFocus(fallbackKey: _kTheme); + } + Future _loadSettings() async { _settingsService = await settings.SettingsService.getInstance(); if (_keyboardShortcutsSupported) { @@ -154,6 +211,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, themeProvider, child) { return ListTile( + focusNode: _focusTracker.get(_kTheme), leading: AppIcon(themeProvider.themeModeIcon, fill: 1), title: Text(t.settings.theme), subtitle: Text(themeProvider.themeModeDisplayName), @@ -163,6 +221,7 @@ class _SettingsScreenState extends State { }, ), ListTile( + focusNode: _focusTracker.get(_kLanguage), leading: const AppIcon(Symbols.language_rounded, fill: 1), title: Text(t.settings.language), subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), @@ -172,6 +231,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, settingsProvider, child) { return ListTile( + focusNode: _focusTracker.get(_kLibraryDensity), leading: const AppIcon(Symbols.grid_view_rounded, fill: 1), title: Text(t.settings.libraryDensity), subtitle: Text(settingsProvider.libraryDensityDisplayName), @@ -183,6 +243,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, settingsProvider, child) { return ListTile( + focusNode: _focusTracker.get(_kViewMode), leading: const AppIcon(Symbols.view_list_rounded, fill: 1), title: Text(t.settings.viewMode), subtitle: Text( @@ -196,6 +257,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, settingsProvider, child) { return ListTile( + focusNode: _focusTracker.get(_kEpisodePosterMode), leading: const AppIcon(Symbols.image_rounded, fill: 1), title: Text(t.settings.episodePosterMode), subtitle: Text(settingsProvider.episodePosterModeDisplayName), @@ -207,6 +269,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, settingsProvider, child) { return SwitchListTile( + focusNode: _focusTracker.get(_kShowHeroSection), secondary: const AppIcon(Symbols.featured_play_list_rounded, fill: 1), title: Text(t.settings.showHeroSection), subtitle: Text(t.settings.showHeroSectionDescription), @@ -220,6 +283,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, settingsProvider, child) { return SwitchListTile( + focusNode: _focusTracker.get(_kUseGlobalHubs), secondary: const AppIcon(Symbols.home_rounded, fill: 1), title: Text(t.settings.useGlobalHubs), subtitle: Text(t.settings.useGlobalHubsDescription), @@ -233,6 +297,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, settingsProvider, child) { return SwitchListTile( + focusNode: _focusTracker.get(_kShowServerNameOnHubs), secondary: const AppIcon(Symbols.dns_rounded, fill: 1), title: Text(t.settings.showServerNameOnHubs), subtitle: Text(t.settings.showServerNameOnHubsDescription), @@ -247,6 +312,7 @@ class _SettingsScreenState extends State { Consumer( builder: (context, settingsProvider, child) { return SwitchListTile( + focusNode: _focusTracker.get(_kAlwaysKeepSidebarOpen), secondary: const AppIcon(Symbols.dock_to_left_rounded, fill: 1), title: Text(t.settings.alwaysKeepSidebarOpen), subtitle: Text(t.settings.alwaysKeepSidebarOpenDescription), @@ -278,6 +344,7 @@ class _SettingsScreenState extends State { ), if (Platform.isAndroid) ListTile( + focusNode: _focusTracker.get(_kPlayerBackend), leading: const AppIcon(Symbols.play_circle_rounded, fill: 1), title: Text(t.settings.playerBackend), subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription), @@ -285,6 +352,7 @@ class _SettingsScreenState extends State { onTap: () => _showPlayerBackendDialog(), ), SwitchListTile( + focusNode: _focusTracker.get(_kHardwareDecoding), secondary: const AppIcon(Symbols.hardware_rounded, fill: 1), title: Text(t.settings.hardwareDecoding), subtitle: Text(t.settings.hardwareDecodingDescription), @@ -298,6 +366,7 @@ class _SettingsScreenState extends State { ), if (Platform.isAndroid) SwitchListTile( + focusNode: _focusTracker.get(_kMatchContentFrameRate), secondary: const AppIcon(Symbols.display_settings_rounded, fill: 1), title: Text(t.settings.matchContentFrameRate), subtitle: Text(t.settings.matchContentFrameRateDescription), @@ -310,6 +379,7 @@ class _SettingsScreenState extends State { }, ), ListTile( + focusNode: _focusTracker.get(_kBufferSize), leading: const AppIcon(Symbols.memory_rounded, fill: 1), title: Text(t.settings.bufferSize), subtitle: Text(t.settings.bufferSizeMB(size: _bufferSize.toString())), @@ -317,6 +387,7 @@ class _SettingsScreenState extends State { onTap: () => _showBufferSizeDialog(), ), ListTile( + focusNode: _focusTracker.get(_kSubtitleStyling), leading: const AppIcon(Symbols.subtitles_rounded, fill: 1), title: Text(t.settings.subtitleStyling), subtitle: Text(t.settings.subtitleStylingDescription), @@ -328,6 +399,7 @@ class _SettingsScreenState extends State { // MPV Config is only available when using MPV player backend if (!Platform.isAndroid || !_useExoPlayer) ListTile( + focusNode: _focusTracker.get(_kMpvConfig), leading: const AppIcon(Symbols.tune_rounded, fill: 1), title: Text(t.mpvConfig.title), subtitle: Text(t.mpvConfig.description), @@ -337,6 +409,7 @@ class _SettingsScreenState extends State { }, ), ListTile( + focusNode: _focusTracker.get(_kSmallSkipDuration), leading: const AppIcon(Symbols.replay_10_rounded, fill: 1), title: Text(t.settings.smallSkipDuration), subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeSmall.toString())), @@ -344,6 +417,7 @@ class _SettingsScreenState extends State { onTap: () => _showSeekTimeSmallDialog(), ), ListTile( + focusNode: _focusTracker.get(_kLargeSkipDuration), leading: const AppIcon(Symbols.replay_30_rounded, fill: 1), title: Text(t.settings.largeSkipDuration), subtitle: Text(t.settings.secondsUnit(seconds: _seekTimeLarge.toString())), @@ -351,6 +425,7 @@ class _SettingsScreenState extends State { onTap: () => _showSeekTimeLargeDialog(), ), ListTile( + focusNode: _focusTracker.get(_kDefaultSleepTimer), leading: const AppIcon(Symbols.bedtime_rounded, fill: 1), title: Text(t.settings.defaultSleepTimer), subtitle: Text(t.settings.minutesUnit(minutes: _sleepTimerDuration.toString())), @@ -358,6 +433,7 @@ class _SettingsScreenState extends State { onTap: () => _showSleepTimerDurationDialog(), ), ListTile( + focusNode: _focusTracker.get(_kMaxVolume), leading: const AppIcon(Symbols.volume_up_rounded, fill: 1), title: Text(t.settings.maxVolume), subtitle: Text(t.settings.maxVolumePercent(percent: _maxVolume.toString())), @@ -366,6 +442,7 @@ class _SettingsScreenState extends State { ), if (DiscordRPCService.isAvailable) SwitchListTile( + focusNode: _focusTracker.get(_kDiscordRichPresence), secondary: const AppIcon(Symbols.chat_rounded, fill: 1), title: Text(t.settings.discordRichPresence), subtitle: Text(t.settings.discordRichPresenceDescription), @@ -377,6 +454,7 @@ class _SettingsScreenState extends State { }, ), SwitchListTile( + focusNode: _focusTracker.get(_kRememberTrackSelections), secondary: const AppIcon(Symbols.bookmark_rounded, fill: 1), title: Text(t.settings.rememberTrackSelections), subtitle: Text(t.settings.rememberTrackSelectionsDescription), @@ -390,6 +468,7 @@ class _SettingsScreenState extends State { ), if (!isMobile) SwitchListTile( + focusNode: _focusTracker.get(_kClickVideoTogglesPlayback), secondary: const AppIcon(Symbols.play_pause_rounded, fill: 1), title: Text(t.settings.clickVideoTogglesPlayback), subtitle: Text(t.settings.clickVideoTogglesPlaybackDescription), @@ -413,6 +492,7 @@ class _SettingsScreenState extends State { ), ), SwitchListTile( + focusNode: _focusTracker.get(_kAutoSkipIntro), secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1), title: Text(t.settings.autoSkipIntro), subtitle: Text(t.settings.autoSkipIntroDescription), @@ -425,6 +505,7 @@ class _SettingsScreenState extends State { }, ), SwitchListTile( + focusNode: _focusTracker.get(_kAutoSkipCredits), secondary: const AppIcon(Symbols.skip_next_rounded, fill: 1), title: Text(t.settings.autoSkipCredits), subtitle: Text(t.settings.autoSkipCreditsDescription), @@ -437,6 +518,7 @@ class _SettingsScreenState extends State { }, ), ListTile( + focusNode: _focusTracker.get(_kAutoSkipDelay), leading: const AppIcon(Symbols.timer_rounded, fill: 1), title: Text(t.settings.autoSkipDelay), subtitle: Text(t.settings.autoSkipDelayDescription(seconds: _autoSkipDelay.toString())), @@ -471,6 +553,7 @@ class _SettingsScreenState extends State { final currentPath = snapshot.data ?? '...'; return ListTile( + focusNode: _focusTracker.get(_kDownloadLocation), leading: const AppIcon(Symbols.folder_rounded, fill: 1), title: Text(isCustom ? t.settings.downloadLocationCustom : t.settings.downloadLocationDefault), subtitle: Text(currentPath, maxLines: 2, overflow: TextOverflow.ellipsis), @@ -480,6 +563,7 @@ class _SettingsScreenState extends State { }, ), SwitchListTile( + focusNode: _focusTracker.get(_kDownloadOnWifiOnly), secondary: const AppIcon(Symbols.wifi_rounded, fill: 1), title: Text(t.settings.downloadOnWifiOnly), subtitle: Text(t.settings.downloadOnWifiOnlyDescription), @@ -613,6 +697,7 @@ class _SettingsScreenState extends State { ), ), ListTile( + focusNode: _focusTracker.get(_kVideoPlayerControls), leading: const AppIcon(Symbols.keyboard_rounded, fill: 1), title: Text(t.settings.videoPlayerControls), subtitle: Text(t.settings.keyboardShortcutsDescription), @@ -620,6 +705,7 @@ class _SettingsScreenState extends State { onTap: () => _showKeyboardShortcutsDialog(), ), SwitchListTile( + focusNode: _focusTracker.get(_kVideoPlayerNavigation), secondary: const AppIcon(Symbols.gamepad_rounded, fill: 1), title: Text(t.settings.videoPlayerNavigation), subtitle: Text(t.settings.videoPlayerNavigationDescription), @@ -649,6 +735,7 @@ class _SettingsScreenState extends State { ), ), SwitchListTile( + focusNode: _focusTracker.get(_kDebugLogging), secondary: const AppIcon(Symbols.bug_report_rounded, fill: 1), title: Text(t.settings.debugLogging), subtitle: Text(t.settings.debugLoggingDescription), @@ -661,6 +748,7 @@ class _SettingsScreenState extends State { }, ), ListTile( + focusNode: _focusTracker.get(_kViewLogs), leading: const AppIcon(Symbols.article_rounded, fill: 1), title: Text(t.settings.viewLogs), subtitle: Text(t.settings.viewLogsDescription), @@ -670,6 +758,7 @@ class _SettingsScreenState extends State { }, ), ListTile( + focusNode: _focusTracker.get(_kClearCache), leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1), title: Text(t.settings.clearCache), subtitle: Text(t.settings.clearCacheDescription), @@ -677,6 +766,7 @@ class _SettingsScreenState extends State { onTap: () => _showClearCacheDialog(), ), ListTile( + focusNode: _focusTracker.get(_kResetSettings), leading: const AppIcon(Symbols.restore_rounded, fill: 1), title: Text(t.settings.resetSettings), subtitle: Text(t.settings.resetSettingsDescription), @@ -703,6 +793,7 @@ class _SettingsScreenState extends State { ), ), ListTile( + focusNode: _focusTracker.get(_kCheckForUpdates), leading: AppIcon( hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded, fill: 1, @@ -731,6 +822,7 @@ class _SettingsScreenState extends State { Widget _buildAboutSection() { return Card( child: ListTile( + focusNode: _focusTracker.get(_kAbout), leading: const AppIcon(Symbols.info_rounded, fill: 1), title: Text(t.settings.about), subtitle: Text(t.settings.aboutDescription), diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index e9cc4dc6..62f34e14 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; +import '../focus/focus_memory_tracker.dart'; import '../models/plex_library.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; @@ -18,67 +19,6 @@ import '../theme/mono_tokens.dart'; import '../utils/content_utils.dart'; import '../i18n/strings.g.dart'; -/// Tracks focus state for a set of named items, avoiding repeated boilerplate -class _FocusStateTracker { - final Map _nodes = {}; - final Set _focused = {}; - final VoidCallback _onChanged; - String? _lastFocusedKey; // Track last focused item for restoration - - _FocusStateTracker(this._onChanged); - - /// Get or create a focus node for the given key - FocusNode get(String key, {String? debugLabel}) { - return _nodes.putIfAbsent(key, () { - final node = FocusNode(debugLabel: debugLabel ?? 'nav_$key'); - node.addListener(() { - final wasFocused = _focused.contains(key); - if (node.hasFocus && !wasFocused) { - _focused.add(key); - _lastFocusedKey = key; // Remember this key for restoration - _onChanged(); - } else if (!node.hasFocus && wasFocused) { - _focused.remove(key); - _onChanged(); - } - }); - return node; - }); - } - - /// Get the last focused key (for focus restoration) - String? get lastFocusedKey => _lastFocusedKey; - - /// Check if a key is currently focused - bool isFocused(String key) => _focused.contains(key); - - /// Check if a node exists for the given key - FocusNode? nodeFor(String key) => _nodes[key]; - - /// Dispose all nodes - void dispose() { - for (final node in _nodes.values) { - node.dispose(); - } - _nodes.clear(); - _focused.clear(); - } - - /// Remove nodes not in the given set of valid keys (prunes stale nodes) - void pruneExcept(Set validKeys) { - final toRemove = _nodes.keys.where((k) => !validKeys.contains(k)).toList(); - for (final key in toRemove) { - _nodes[key]?.dispose(); - _nodes.remove(key); - _focused.remove(key); - } - // Clear last focused if it was pruned - if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) { - _lastFocusedKey = null; - } - } -} - /// Reusable navigation rail item widget that handles focus, selection, and interaction class NavigationRailItem extends StatelessWidget { final IconData icon; @@ -221,7 +161,7 @@ class SideNavigationRailState extends State { static const _kSettings = 'settings'; // Unified focus state tracker for all nav items (main + libraries) - late final _FocusStateTracker _focusTracker; + late final FocusMemoryTracker _focusTracker; /// Whether the sidebar should be expanded (always, hover, or focus) bool get _shouldExpand => widget.alwaysExpanded || _isHovered || widget.isSidebarFocused; @@ -229,9 +169,12 @@ class SideNavigationRailState extends State { @override void initState() { super.initState(); - _focusTracker = _FocusStateTracker(() { - if (mounted) setState(() {}); - }); + _focusTracker = FocusMemoryTracker( + onFocusChanged: () { + if (mounted) setState(() {}); + }, + debugLabelPrefix: 'nav', + ); _loadLibraries(); } @@ -269,16 +212,7 @@ class SideNavigationRailState extends State { /// Focus the last focused nav item, or Home as fallback void focusActiveItem() { - // Try to restore last focused item - if (_focusTracker.lastFocusedKey != null) { - final node = _focusTracker.nodeFor(_focusTracker.lastFocusedKey!); - if (node != null) { - node.requestFocus(); - return; - } - } - // Fallback: focus Home if nothing was previously focused - _focusTracker.nodeFor(_kHome)?.requestFocus(); + _focusTracker.restoreFocus(fallbackKey: _kHome); } /// Fetch, filter, and order libraries (pure logic, no state changes)