feat: save focus on settings screen

This commit is contained in:
edde746
2026-01-24 15:32:08 +01:00
parent 54e8e0c621
commit 5e3178e6fe
4 changed files with 224 additions and 91 deletions
+88
View File
@@ -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<String, FocusNode> _nodes = {};
final Set<String> _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<String> 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();
}
}
+34 -15
View File
@@ -399,13 +399,22 @@ class _MainScreenState extends State<MainScreen> 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<MainScreen> 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();
}
}
}
+93 -1
View File
@@ -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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
class _SettingsScreenState extends State<SettingsScreen> 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<SettingsScreen> {
@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<void> _loadSettings() async {
_settingsService = await settings.SettingsService.getInstance();
if (_keyboardShortcutsSupported) {
@@ -154,6 +211,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Consumer<ThemeProvider>(
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<SettingsScreen> {
},
),
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<SettingsScreen> {
Consumer<SettingsProvider>(
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<SettingsScreen> {
Consumer<SettingsProvider>(
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<SettingsScreen> {
Consumer<SettingsProvider>(
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<SettingsScreen> {
Consumer<SettingsProvider>(
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<SettingsScreen> {
Consumer<SettingsProvider>(
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<SettingsScreen> {
Consumer<SettingsProvider>(
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<SettingsScreen> {
Consumer<SettingsProvider>(
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<SettingsScreen> {
),
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<SettingsScreen> {
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<SettingsScreen> {
),
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<SettingsScreen> {
},
),
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<SettingsScreen> {
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<SettingsScreen> {
// 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<SettingsScreen> {
},
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
),
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<SettingsScreen> {
},
),
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<SettingsScreen> {
),
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<SettingsScreen> {
),
),
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<SettingsScreen> {
},
),
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<SettingsScreen> {
},
),
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<SettingsScreen> {
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<SettingsScreen> {
},
),
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<SettingsScreen> {
),
),
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<SettingsScreen> {
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<SettingsScreen> {
),
),
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<SettingsScreen> {
},
),
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<SettingsScreen> {
},
),
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<SettingsScreen> {
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<SettingsScreen> {
),
),
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<SettingsScreen> {
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),
+9 -75
View File
@@ -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<String, FocusNode> _nodes = {};
final Set<String> _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<String> 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<SideNavigationRail> {
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<SideNavigationRail> {
@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<SideNavigationRail> {
/// 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)