refactor: streamline settings bindings

This commit is contained in:
edde746
2026-05-02 08:44:27 +02:00
parent 8fabf56337
commit 130e0d6f40
48 changed files with 2535 additions and 3072 deletions
+99 -178
View File
@@ -21,7 +21,6 @@ import '../../services/download_storage_service.dart';
import '../../services/file_picker_service.dart';
import '../../services/saf_storage_service.dart';
import '../../services/settings_export_service.dart';
import '../../providers/settings_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/trackers_provider.dart';
import '../../providers/trakt_account_provider.dart';
@@ -34,6 +33,8 @@ import '../../utils/platform_detector.dart';
import '../../utils/update_dialog.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/dialog_action_button.dart';
import '../../widgets/setting_tile.dart';
import '../../widgets/settings_builder.dart';
import '../../widgets/settings_section.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/profile.dart';
@@ -56,7 +57,6 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
late settings.SettingsService _settingsService;
late final FocusMemoryTracker _focusTracker;
// Focus tracking keys
@@ -83,15 +83,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
KeyboardShortcutsService? _keyboardService;
late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported();
bool _isLoading = true;
bool _crashReporting = true;
bool _enableDebugLogging = false;
bool _downloadOnWifiOnly = false;
bool _autoRemoveWatchedDownloads = false;
bool _autoCheckUpdatesOnStartup = true;
bool _videoPlayerNavigationEnabled = false;
String? _customRelayUrl;
// Update checking state
bool _isCheckingForUpdate = false;
@@ -107,7 +98,11 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
},
debugLabelPrefix: 'settings',
);
_loadSettings();
if (_keyboardShortcutsSupported) {
KeyboardShortcutsService.getInstance().then((s) {
if (mounted) setState(() => _keyboardService = s);
});
}
}
@override
@@ -135,31 +130,10 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
return KeyEventResult.ignored;
}
Future<void> _loadSettings() async {
_settingsService = await settings.SettingsService.getInstance();
if (_keyboardShortcutsSupported) {
_keyboardService = await KeyboardShortcutsService.getInstance();
}
if (!mounted) return;
setState(() {
_crashReporting = _settingsService.read(settings.SettingsService.crashReporting);
_enableDebugLogging = _settingsService.read(settings.SettingsService.enableDebugLogging);
_downloadOnWifiOnly = _settingsService.read(settings.SettingsService.downloadOnWifiOnly);
_autoRemoveWatchedDownloads = _settingsService.read(settings.SettingsService.autoRemoveWatchedDownloads);
_autoCheckUpdatesOnStartup = _settingsService.read(settings.SettingsService.autoCheckUpdatesOnStartup);
_videoPlayerNavigationEnabled = _settingsService.read(settings.SettingsService.videoPlayerNavigationEnabled);
_customRelayUrl = _settingsService.read(settings.SettingsService.customRelayUrl);
_isLoading = false;
});
}
settings.SettingsService get _settingsService => settings.SettingsService.instanceOrNull!;
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
body: Focus(
onKeyEvent: _handleKeyEvent,
@@ -191,15 +165,12 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
if (!PlatformDetector.isTV()) _buildBackupSection(),
ListTile(
SettingNavigationTile(
focusNode: _focusTracker.get(_kAbout),
leading: const AppIcon(Symbols.info_rounded, fill: 1),
title: Text(t.settings.about),
subtitle: Text(t.settings.aboutDescription),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const AboutScreen()));
},
icon: Symbols.info_rounded,
title: t.settings.about,
subtitle: t.settings.aboutDescription,
destinationBuilder: (context) => const AboutScreen(),
),
const SizedBox(height: 24),
]),
@@ -227,34 +198,30 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
}
Widget _buildAppearanceTile() {
return Consumer2<ThemeProvider, SettingsProvider>(
builder: (context, themeProvider, settingsProvider, child) {
final summary =
'${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} ${settingsProvider.libraryDensity}';
return ListTile(
focusNode: _focusTracker.get(_kAppearance),
leading: const AppIcon(Symbols.palette_rounded, fill: 1),
title: Text(t.settings.appearance),
subtitle: Text(summary),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const AppearanceSettingsScreen()));
},
);
},
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) => SettingValueBuilder<int>(
pref: settings.SettingsService.libraryDensity,
builder: (context, libraryDensity, _) {
final summary = '${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} $libraryDensity';
return SettingNavigationTile(
focusNode: _focusTracker.get(_kAppearance),
icon: Symbols.palette_rounded,
title: t.settings.appearance,
subtitle: summary,
destinationBuilder: (context) => const AppearanceSettingsScreen(),
);
},
),
);
}
Widget _buildPlaybackTile() {
return ListTile(
return SettingNavigationTile(
focusNode: _focusTracker.get(_kPlayback),
leading: const AppIcon(Symbols.play_circle_rounded, fill: 1),
title: Text(t.settings.videoPlayback),
subtitle: Text(t.settings.videoPlaybackDescription),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const PlaybackSettingsScreen()));
},
icon: Symbols.play_circle_rounded,
title: t.settings.videoPlayback,
subtitle: t.settings.videoPlaybackDescription,
destinationBuilder: (context) => const PlaybackSettingsScreen(),
);
}
@@ -268,21 +235,23 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
if (trackers.isSimklConnected) t.trackers.services.simkl,
];
final subtitle = connectedNames.isEmpty ? t.settings.trackersDescription : connectedNames.join(' · ');
return ListTile(
return SettingNavigationTile(
focusNode: _focusTracker.get(_kTrackers),
leading: const AppIcon(Symbols.sync_rounded, fill: 1),
title: Text(t.settings.trackers),
subtitle: Text(subtitle),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const TrackersSettingsScreen()));
},
icon: Symbols.sync_rounded,
title: t.settings.trackers,
subtitle: subtitle,
destinationBuilder: (_) => const TrackersSettingsScreen(),
);
},
);
}
Widget _buildConnectionsSection() {
final active = context.select<ActiveProfileProvider, Profile?>((p) => p.active);
final subtitle = active == null
? t.connections.addConnectionSubtitleNoProfile
: t.connections.addConnectionSubtitleScoped(displayName: active.displayName);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -291,22 +260,10 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
// and each profile's detail screen). The shortcut here just opens
// the picker scoped to the active profile so users can add a Plex
// account, Jellyfin server, or borrow from another profile.
ListTile(
leading: const AppIcon(Symbols.add_link_rounded, fill: 1),
title: Text(t.connections.addConnection),
subtitle: Builder(
builder: (context) {
// Select only the active profile slice — `context.watch`
// would rebuild on every provider notification.
final active = context.select<ActiveProfileProvider, Profile?>((p) => p.active);
return Text(
active == null
? t.connections.addConnectionSubtitleNoProfile
: t.connections.addConnectionSubtitleScoped(displayName: active.displayName),
);
},
),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
SettingNavigationTile(
icon: Symbols.add_link_rounded,
title: t.connections.addConnection,
subtitle: subtitle,
onTap: () {
final active = context.read<ActiveProfileProvider>().active;
Navigator.push(context, MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: active)));
@@ -330,14 +287,11 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
: (activeName != null
? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName)
: t.profiles.summaryMultiple(count: count));
return ListTile(
leading: const AppIcon(Symbols.group_rounded, fill: 1),
title: Text(t.profiles.sectionTitle),
subtitle: Text(subtitle),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProfileSwitchScreen()));
},
return SettingNavigationTile(
icon: Symbols.group_rounded,
title: t.profiles.sectionTitle,
subtitle: subtitle,
destinationBuilder: (_) => const ProfileSwitchScreen(),
);
},
);
@@ -366,27 +320,19 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
);
},
),
SwitchListTile(
SettingSwitchTile(
focusNode: _focusTracker.get(_kDownloadOnWifiOnly),
secondary: const AppIcon(Symbols.wifi_rounded, fill: 1),
title: Text(t.settings.downloadOnWifiOnly),
subtitle: Text(t.settings.downloadOnWifiOnlyDescription),
value: _downloadOnWifiOnly,
onChanged: (value) async {
setState(() => _downloadOnWifiOnly = value);
await _settingsService.write(settings.SettingsService.downloadOnWifiOnly, value);
},
pref: settings.SettingsService.downloadOnWifiOnly,
icon: Symbols.wifi_rounded,
title: t.settings.downloadOnWifiOnly,
subtitle: t.settings.downloadOnWifiOnlyDescription,
),
SwitchListTile(
SettingSwitchTile(
focusNode: _focusTracker.get(_kAutoRemoveWatchedDownloads),
secondary: const AppIcon(Symbols.delete_sweep_rounded, fill: 1),
title: Text(t.settings.autoRemoveWatchedDownloads),
subtitle: Text(t.settings.autoRemoveWatchedDownloadsDescription),
value: _autoRemoveWatchedDownloads,
onChanged: (value) async {
setState(() => _autoRemoveWatchedDownloads = value);
await _settingsService.write(settings.SettingsService.autoRemoveWatchedDownloads, value);
},
pref: settings.SettingsService.autoRemoveWatchedDownloads,
icon: Symbols.delete_sweep_rounded,
title: t.settings.autoRemoveWatchedDownloads,
subtitle: t.settings.autoRemoveWatchedDownloadsDescription,
),
],
);
@@ -399,12 +345,11 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSectionHeader(t.settings.keyboardShortcuts),
ListTile(
SettingNavigationTile(
focusNode: _focusTracker.get(_kVideoPlayerControls),
leading: const AppIcon(Symbols.keyboard_rounded, fill: 1),
title: Text(t.settings.videoPlayerControls),
subtitle: Text(t.settings.keyboardShortcutsDescription),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
icon: Symbols.keyboard_rounded,
title: t.settings.videoPlayerControls,
subtitle: t.settings.keyboardShortcutsDescription,
onTap: () {
Navigator.push(
context,
@@ -412,16 +357,12 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
);
},
),
SwitchListTile(
SettingSwitchTile(
focusNode: _focusTracker.get(_kVideoPlayerNavigation),
secondary: const AppIcon(Symbols.gamepad_rounded, fill: 1),
title: Text(t.settings.videoPlayerNavigation),
subtitle: Text(t.settings.videoPlayerNavigationDescription),
value: _videoPlayerNavigationEnabled,
onChanged: (value) async {
setState(() => _videoPlayerNavigationEnabled = value);
await _settingsService.write(settings.SettingsService.videoPlayerNavigationEnabled, value);
},
pref: settings.SettingsService.videoPlayerNavigationEnabled,
icon: Symbols.gamepad_rounded,
title: t.settings.videoPlayerNavigation,
subtitle: t.settings.videoPlayerNavigationDescription,
),
],
);
@@ -440,37 +381,26 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showRelayUrlDialog(),
),
SwitchListTile(
SettingSwitchTile(
focusNode: _focusTracker.get(_kCrashReporting),
secondary: const AppIcon(Symbols.monitoring_rounded, fill: 1),
title: Text(t.settings.crashReporting),
subtitle: Text(t.settings.crashReportingDescription),
value: _crashReporting,
onChanged: (value) async {
setState(() => _crashReporting = value);
await _settingsService.write(settings.SettingsService.crashReporting, value);
},
pref: settings.SettingsService.crashReporting,
icon: Symbols.monitoring_rounded,
title: t.settings.crashReporting,
subtitle: t.settings.crashReportingDescription,
),
SwitchListTile(
SettingSwitchTile(
focusNode: _focusTracker.get(_kDebugLogging),
secondary: const AppIcon(Symbols.bug_report_rounded, fill: 1),
title: Text(t.settings.debugLogging),
subtitle: Text(t.settings.debugLoggingDescription),
value: _enableDebugLogging,
onChanged: (value) async {
setState(() => _enableDebugLogging = value);
await _settingsService.write(settings.SettingsService.enableDebugLogging, value);
},
pref: settings.SettingsService.enableDebugLogging,
icon: Symbols.bug_report_rounded,
title: t.settings.debugLogging,
subtitle: t.settings.debugLoggingDescription,
),
ListTile(
SettingNavigationTile(
focusNode: _focusTracker.get(_kViewLogs),
leading: const AppIcon(Symbols.article_rounded, fill: 1),
title: Text(t.settings.viewLogs),
subtitle: Text(t.settings.viewLogsDescription),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen()));
},
icon: Symbols.article_rounded,
title: t.settings.viewLogs,
subtitle: t.settings.viewLogsDescription,
destinationBuilder: (context) => const LogsScreen(),
),
ListTile(
focusNode: _focusTracker.get(_kClearCache),
@@ -539,19 +469,13 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
);
}
Widget _buildAutoCheckUpdatesOnStartupTile() {
return SwitchListTile(
focusNode: _focusTracker.get(_kAutoCheckUpdatesOnStartup),
secondary: const AppIcon(Symbols.notifications_active_rounded, fill: 1),
title: Text(t.settings.autoCheckUpdatesOnStartup),
subtitle: Text(t.settings.autoCheckUpdatesOnStartupDescription),
value: _autoCheckUpdatesOnStartup,
onChanged: (value) async {
setState(() => _autoCheckUpdatesOnStartup = value);
await _settingsService.write(settings.SettingsService.autoCheckUpdatesOnStartup, value);
},
);
}
Widget _buildAutoCheckUpdatesOnStartupTile() => SettingSwitchTile(
focusNode: _focusTracker.get(_kAutoCheckUpdatesOnStartup),
pref: settings.SettingsService.autoCheckUpdatesOnStartup,
icon: Symbols.notifications_active_rounded,
title: t.settings.autoCheckUpdatesOnStartup,
subtitle: t.settings.autoCheckUpdatesOnStartupDescription,
);
Widget _buildUpdateSection() {
if (UpdateService.useNativeUpdater) {
@@ -718,7 +642,9 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
}
Future<void> _showRelayUrlDialog() async {
final controller = TextEditingController(text: _customRelayUrl ?? '');
final controller = TextEditingController(
text: _settingsService.read(settings.SettingsService.customRelayUrl) ?? '',
);
final saveFocusNode = FocusNode();
// try/finally guarantees disposal on every dismissal path (button,
// back, tap-outside) without depending on `.then` chaining.
@@ -740,7 +666,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
onPressed: () async {
controller.clear();
await _settingsService.write(settings.SettingsService.customRelayUrl, null);
if (mounted) setState(() => _customRelayUrl = null);
if (dialogContext.mounted) Navigator.pop(dialogContext);
},
label: t.settings.resetToDefault,
@@ -751,7 +676,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
onPressed: () async {
final url = controller.text.trim().isEmpty ? null : controller.text.trim();
await _settingsService.write(settings.SettingsService.customRelayUrl, url);
if (mounted) setState(() => _customRelayUrl = url);
if (dialogContext.mounted) Navigator.pop(dialogContext);
},
label: t.common.save,
@@ -789,10 +713,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
if (!confirmed) return;
await _settingsService.resetAllSettings();
await _keyboardService?.resetToDefaults();
if (mounted) {
showSuccessSnackBar(context, t.settings.resetSettingsSuccess);
unawaited(_loadSettings());
}
if (mounted) showSuccessSnackBar(context, t.settings.resetSettingsSuccess);
}
Future<void> _handleExportSettings() async {
@@ -823,7 +744,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
// Capture providers before any awaits so we don't reach through `context`
// after the widget may have been unmounted.
final themeProvider = context.read<ThemeProvider>();
final settingsProvider = context.read<SettingsProvider>();
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
final librariesProvider = context.read<LibrariesProvider>();
@@ -832,17 +752,18 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
if (!mounted) return;
if (result == null) return; // user cancelled file picker
// Import wrote directly to SharedPreferences, bypassing `write`. Push
// fresh values into active listenables before providers re-read settings.
_settingsService.refreshListenables();
unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale)));
await Future.wait([
themeProvider.reload(),
settingsProvider.reload(),
hiddenLibrariesProvider.refresh(),
if (_keyboardService != null) _keyboardService!.refreshFromStorage(),
]);
unawaited(librariesProvider.refresh());
if (!mounted) return;
unawaited(_loadSettings());
showSuccessSnackBar(context, t.settings.importSettingsSuccess);
} on NoUserSignedInException {
if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser);