import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; 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 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../focus/focus_memory_tracker.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; import '../main_screen.dart'; import '../../mixins/mounted_set_state_mixin.dart'; import '../../mixins/refreshable.dart'; import '../../providers/account_preferences_controller.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/download_provider.dart'; import '../../providers/libraries_provider.dart'; import '../../services/donation_service.dart'; 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/theme_provider.dart'; import '../../providers/seerr_account_provider.dart'; import '../../services/account_preferences_accounts.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/companion_remote/companion_remote_host_controller.dart'; import '../../services/background_work_diagnostics_service.dart'; import '../../services/settings_service.dart' as settings; import '../../widgets/background_download_warning_banner.dart'; import '../../services/update_service.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; 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/overlay_sheet.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; import '../../widgets/system_bottom_inset.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.dart'; import '../../watch_together/services/watch_together_relay_endpoint.dart'; import 'about_screen.dart'; import 'add_connection_screen.dart'; import 'account_preferences_screen.dart'; import 'appearance_settings_screen.dart'; import 'general_settings_screen.dart'; import 'keyboard_shortcuts_screen.dart'; import 'logs_screen.dart'; import 'playback_settings_screen.dart'; import '../profile/profile_switch_screen.dart'; import 'services_settings_screen.dart'; import 'settings_utils.dart'; import 'tracker_service_info.dart'; import '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, this.downloadDirectoryWritableChecker, this.settingsExporter, this.settingsImporter, this.backgroundWorkDiagnosticsService, }); @visibleForTesting final Future Function(Directory directory)? downloadDirectoryWritableChecker; @visibleForTesting final Future Function()? settingsExporter; @visibleForTesting final Future Function()? settingsImporter; @visibleForTesting final BackgroundWorkDiagnosticsService? backgroundWorkDiagnosticsService; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State with FocusableTab, MountedSetStateMixin { BackgroundWorkDiagnosticsService get _backgroundWorkDiagnostics => widget.backgroundWorkDiagnosticsService ?? BackgroundWorkDiagnosticsService.instance; late final FocusMemoryTracker _focusTracker; // Focus tracking keys static const _kDonate = 'donate'; static const _kGeneral = 'general'; static const _kAppearance = 'appearance'; static const _kPlayback = 'playback'; static const _kManageLibraries = 'manage_libraries'; static const _kServices = 'services'; static const _kDownloadLocation = 'download_location'; static const _kDownloadOnWifiOnly = 'download_on_wifi_only'; static const _kAutoRemoveWatchedDownloads = 'auto_remove_watched_downloads'; static const _kBackgroundDownloads = 'background_downloads'; static const _kVideoPlayerControls = 'video_player_controls'; static const _kVideoPlayerNavigation = 'video_player_navigation'; static const _kCompanionRemoteServer = 'companion_remote_server'; static const _kCrashReporting = 'crash_reporting'; static const _kDebugLogging = 'debug_logging'; static const _kAutoHidePerformanceOverlay = 'auto_hide_performance_overlay'; static const _kViewLogs = 'view_logs'; static const _kClearImageCache = 'clear_image_cache'; static const _kResetSettings = 'reset_settings'; static const _kCheckForUpdates = 'check_for_updates'; static const _kAutoCheckUpdatesOnStartup = 'auto_check_updates_on_startup'; static const _kAbout = 'about'; static const _kWatchTogetherRelay = 'watch_together_relay'; static const _kExportSettings = 'export_settings'; static const _kImportSettings = 'import_settings'; static const _kAccountPreferences = 'account_preferences'; KeyboardShortcutsService? _keyboardService; late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported(); // Update checking state bool _isCheckingForUpdate = false; Map? _updateInfo; @override void initState() { super.initState(); _focusTracker = FocusMemoryTracker(debugLabelPrefix: 'settings'); if (_keyboardShortcutsSupported) { KeyboardShortcutsService.getInstance().then((s) { setStateIfMounted(() => _keyboardService = s); }); } } @override void dispose() { _focusTracker.dispose(); super.dispose(); } @override void focusActiveTabIfReady() { if (InputModeTracker.isKeyboardMode(context, listen: false)) { _focusTracker.restoreFocus(fallbackKey: DonationService.isEnabled ? _kDonate : _kGeneral); } } void _navigateToSidebar() { MainScreenFocusScope.focusSidebarOf(context); } KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowLeft) { _navigateToSidebar(); return KeyEventResult.handled; } return KeyEventResult.ignored; } settings.SettingsService get _settingsService => settings.SettingsService.instance; @override Widget build(BuildContext context) { final hasLibraries = context.select((p) => p.libraries.isNotEmpty); if (OverlaySheetController.maybeOf(context) != null) { return _buildContent(context, hasLibraries: hasLibraries); } // Settings is hosted by MainScreen on side-navigation layouts, but it is a // separate pushed route on phones. Add a route-local host only for the // latter, and build its content from below the host so adaptive sheets do // not fall back to modal routes with a competing Android back path. return OverlaySheetHost( canPop: true, child: Builder(builder: (hostContext) => _buildContent(hostContext, hasLibraries: hasLibraries)), ); } Widget _buildContent(BuildContext sheetContext, {required bool hasLibraries}) { return Scaffold( body: Focus( onKeyEvent: _handleKeyEvent, child: CustomScrollView( primary: false, slivers: [ ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)), SliverList( delegate: SliverChildListDelegate([ const SizedBox(height: 8), SettingsGroup( children: [ if (DonationService.isEnabled) _buildDonateTile(), _buildGeneralTile(), _buildAppearanceTile(), _buildPlaybackTile(), if (hasLibraries) _buildManageLibrariesTile(sheetContext), _buildServicesTile(), ], ), _buildConnectionsSection(sheetContext), if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), if (_keyboardShortcutsSupported || PlatformDetector.shouldActAsRemoteHost(sheetContext)) _buildControlsSection(sheetContext), _buildAdvancedSection(), if (UpdateService.isUpdateCheckAvailable) ...[_buildUpdateSection()], // Hidden on Android TV / tvOS (no document picker); desktop in // force-TV mode keeps it — FilePickerService works there. if (!PlatformDetector.isTV() || PlatformDetector.isDesktopOS()) _buildBackupSection(), const SizedBox(height: 24), SettingsGroup( children: [ SettingNavigationTile( focusNode: _focusTracker.get(_kAbout), icon: Symbols.info_rounded, title: t.settings.about, subtitle: t.settings.aboutDescription, destinationBuilder: (context) => const AboutScreen(), ), ], ), const SizedBox(height: 24), ]), ), const SliverSystemBottomInset(), ], ), ), ); } Widget _buildGeneralTile() { return SettingNavigationTile( focusNode: _focusTracker.get(_kGeneral), icon: Symbols.settings_rounded, title: t.settings.general, subtitle: t.settings.generalDescription, destinationBuilder: (context) => const GeneralSettingsScreen(), ); } Widget _buildDonateTile() { return SettingNavigationTile( focusNode: _focusTracker.get(_kDonate), icon: Symbols.favorite_rounded, title: t.settings.supportDeveloper, subtitle: t.settings.supportDeveloperDescription, trailingIcon: Symbols.open_in_new_rounded, onTap: () async { final url = Uri.parse(DonationService.donationUrl); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } }, ); } Widget _buildAppearanceTile() { return Consumer( builder: (context, themeProvider, _) => SettingValueBuilder( pref: settings.SettingsService.libraryDensity, builder: (context, libraryDensity, _) { final summary = '${themeModeLabel(themeProvider.themeMode)} · ${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 SettingNavigationTile( focusNode: _focusTracker.get(_kPlayback), icon: Symbols.play_circle_rounded, title: t.settings.videoPlayback, subtitle: t.settings.videoPlaybackDescription, destinationBuilder: (context) => const PlaybackSettingsScreen(), ); } Widget _buildManageLibrariesTile(BuildContext context) { return SettingNavigationTile( focusNode: _focusTracker.get(_kManageLibraries), icon: Symbols.video_library_rounded, title: t.libraries.manageLibraries, subtitle: t.settings.manageLibrariesDescription, onTap: () => showLibraryManagementSheet(context), ); } Widget _buildServicesTile() { // The tracker account providers are watched through [TrackerServiceInfo]. return Consumer( builder: (context, seerr, _) { final connectedNames = [ for (final info in TrackerServiceInfo.all) if (info.isConnected(context)) info.displayName, if (seerr.isConnected) t.services.names.seerr, ]; final subtitle = connectedNames.isEmpty ? t.settings.servicesDescription : connectedNames.join(' · '); return SettingNavigationTile( focusNode: _focusTracker.get(_kServices), icon: Symbols.sync_rounded, title: t.settings.services, subtitle: subtitle, destinationBuilder: (_) => const ServicesSettingsScreen(), ); }, ); } Widget _buildConnectionsSection(BuildContext context) { final active = context.select((p) => p.active); final subtitle = active == null ? t.connections.addConnectionSubtitleNoProfile : t.connections.addConnectionSubtitleScoped(displayName: active.displayName); return SettingsGroup( title: t.connections.sectionTitle, children: [ // Connections are managed per-profile (via the Profiles section // 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. SettingNavigationTile( icon: Symbols.add_link_rounded, title: t.connections.addConnection, subtitle: subtitle, onTap: () { final active = context.read().active; Navigator.push(context, MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: active))); }, ), _buildProfilesTile(context), if (context.select((c) => c.accounts.isNotEmpty)) _buildAccountPreferencesTile(context), ], ); } /// Server-stored preferences for the accounts the active profile signed in /// with. Hidden when no account is reachable — an empty picker is noise. Widget _buildAccountPreferencesTile(BuildContext context) { final accounts = context.select>((c) => c.accounts); final subtitle = accounts.length == 1 ? t.accountPreferences.hubSubtitleSingle(account: accounts.single.target.label) : t.accountPreferences.hubSubtitleMultiple(count: accounts.length); return SettingNavigationTile( focusNode: _focusTracker.get(_kAccountPreferences), icon: Symbols.manage_accounts_rounded, title: t.accountPreferences.sectionTitle, subtitle: subtitle, destinationBuilder: (context) => AccountPreferencesScreen(targets: [for (final account in accounts) account.target]), ); } Widget _buildProfilesTile(BuildContext context) { // ActiveProfileProvider already merges local rows with virtual Plex // Home profiles — counting only the local DB rows made every Plex Home // household read as a single profile here. `context.select` keeps // rebuilds scoped to actual count/name changes (a StreamBuilder here // was also re-created on every settings rebuild). final count = context.select((p) => p.profiles.length); final activeName = context.select((p) => p.active?.displayName); final subtitle = count <= 1 ? t.profiles.summarySingle : (activeName != null ? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName) : t.profiles.summaryMultiple(count: count)); return SettingNavigationTile( icon: Symbols.group_rounded, title: t.profiles.sectionTitle, subtitle: subtitle, onTap: () => Navigator.of( context, rootNavigator: true, ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())), ); } Widget _buildDownloadsSection() { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); return SettingsGroup( title: t.settings.downloads, children: [ if (!Platform.isIOS) FutureBuilder( future: storageService.getCurrentDownloadPathDisplay(), builder: (context, snapshot) { final currentPath = snapshot.data ?? '...'; return FocusableListTile( 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: .ellipsis), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showDownloadLocationDialog(), ); }, ), SettingSwitchTile( focusNode: _focusTracker.get(_kDownloadOnWifiOnly), pref: settings.SettingsService.downloadOnWifiOnly, icon: Symbols.wifi_rounded, title: t.settings.downloadOnWifiOnly, subtitle: t.settings.downloadOnWifiOnlyDescription, ), SettingSwitchTile( focusNode: _focusTracker.get(_kAutoRemoveWatchedDownloads), pref: settings.SettingsService.autoRemoveWatchedDownloads, icon: Symbols.delete_sweep_rounded, title: t.settings.autoRemoveWatchedDownloads, subtitle: t.settings.autoRemoveWatchedDownloadsDescription, ), if (_backgroundWorkDiagnostics.isSupported) _buildBackgroundDownloadsTile(), // TODO: "Remove orphaned downloads" toggle (#1413) goes here, next to // autoRemoveWatchedDownloads. ], ); } /// Standing answer to "why do my downloads stop when I leave the app" — also /// what support can ask a user to read out without needing a log upload. Widget _buildBackgroundDownloadsTile() { final diagnostics = _backgroundWorkDiagnostics; return ListenableBuilder( listenable: diagnostics, builder: (context, _) { final status = diagnostics.status; final scheme = Theme.of(context).colorScheme; final (icon, color, summary) = switch (status) { _ when !status.probed => (Symbols.help_rounded, null, t.downloads.backgroundWarning.statusUnknown), _ when status.isBlocked => ( Symbols.battery_alert_rounded, scheme.error, t.downloads.backgroundWarning.statusBlocked, ), _ when !status.isHealthy => ( Symbols.info_rounded, scheme.tertiary, t.downloads.backgroundWarning.statusDegraded, ), _ => (Symbols.check_circle_rounded, null, t.downloads.backgroundWarning.statusOk), }; return FocusableListTile( focusNode: _focusTracker.get(_kBackgroundDownloads), leading: AppIcon(icon, fill: 1, color: color), title: Text(t.downloads.backgroundWarning.statusTile), subtitle: Text(summary), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () async { await diagnostics.refresh(); if (!context.mounted) return; if (diagnostics.status.isHealthy) { showAppSnackBar(context, t.downloads.backgroundWarning.statusOk); return; } await showBackgroundDownloadWarningDialog(context, service: diagnostics); }, ); }, ); } /// Input devices: keyboard shortcuts on platforms with a physical keyboard, /// and the companion-remote host on surfaces that can be controlled from a /// phone. Either half may be absent; the caller skips the section when both /// are. Widget _buildControlsSection(BuildContext context) { final children = [ if (_keyboardService != null) ...[ SettingNavigationTile( focusNode: _focusTracker.get(_kVideoPlayerControls), icon: Symbols.keyboard_rounded, title: t.settings.videoPlayerControls, subtitle: t.settings.keyboardShortcutsDescription, onTap: () { Navigator.push( context, MaterialPageRoute(builder: (context) => KeyboardShortcutsScreen(keyboardService: _keyboardService!)), ); }, ), SettingSwitchTile( focusNode: _focusTracker.get(_kVideoPlayerNavigation), pref: settings.SettingsService.videoPlayerNavigationEnabled, icon: Symbols.gamepad_rounded, title: t.settings.videoPlayerNavigation, subtitle: t.settings.videoPlayerNavigationDescription, ), ], if (PlatformDetector.shouldActAsRemoteHost(context)) SettingSwitchTile( focusNode: _focusTracker.get(_kCompanionRemoteServer), pref: settings.SettingsService.enableCompanionRemoteServer, icon: Symbols.phone_android_rounded, title: t.settings.companionRemoteServer, subtitle: t.settings.companionRemoteServerDescription, onAfterWrite: (v) => applyCompanionRemoteServerSetting(context, v), ), ]; // Keyboard platforms render nothing until the shortcuts service loads; an // empty SettingsGroup would paint a bare section title. if (children.isEmpty) return const SizedBox.shrink(); return SettingsGroup(title: t.settings.controls, children: children); } Widget _buildAdvancedSection() { return SettingsGroup( title: t.settings.advanced, children: [ SettingNavigationTile( focusNode: _focusTracker.get(_kWatchTogetherRelay), icon: Symbols.dns_rounded, title: t.settings.watchTogetherRelay, subtitle: t.settings.watchTogetherRelayDescription, onTap: () => _showRelayUrlDialog(), ), SettingSwitchTile( focusNode: _focusTracker.get(_kCrashReporting), pref: settings.SettingsService.crashReporting, icon: Symbols.monitoring_rounded, title: t.settings.crashReporting, subtitle: t.settings.crashReportingDescription, ), SettingSwitchTile( focusNode: _focusTracker.get(_kDebugLogging), pref: settings.SettingsService.enableDebugLogging, icon: Symbols.bug_report_rounded, title: t.settings.debugLogging, subtitle: t.settings.debugLoggingDescription, ), SettingSwitchTile( focusNode: _focusTracker.get(_kAutoHidePerformanceOverlay), pref: settings.SettingsService.autoHidePerformanceOverlay, icon: Symbols.speed_rounded, title: t.settings.autoHidePerformanceOverlay, subtitle: t.settings.autoHidePerformanceOverlayDescription, ), SettingNavigationTile( focusNode: _focusTracker.get(_kViewLogs), icon: Symbols.article_rounded, title: t.settings.viewLogs, subtitle: t.settings.viewLogsDescription, destinationBuilder: (context) => const LogsScreen(), ), SettingNavigationTile( focusNode: _focusTracker.get(_kClearImageCache), icon: Symbols.cleaning_services_rounded, title: t.settings.clearImageCache, subtitle: t.settings.clearImageCacheDescription, onTap: () => _showClearImageCacheDialog(), ), SettingNavigationTile( focusNode: _focusTracker.get(_kResetSettings), icon: Symbols.restore_rounded, title: t.settings.resetSettings, subtitle: t.settings.resetSettingsDescription, onTap: () => _showResetSettingsDialog(), ), if (kDebugMode) SettingNavigationTile( icon: Symbols.error_rounded, title: 'Test Sentry', subtitle: 'Send a test error', onTap: () { throw Exception("Example exception"); }, ), if (kDebugMode) SettingNavigationTile( icon: Symbols.timer_rounded, title: 'Test ANR', subtitle: 'Block the main thread for 10 seconds', onTap: () { showSnackBar(context, 'Blocking main thread...'); final end = DateTime.now().add(const Duration(seconds: 10)); while (DateTime.now().isBefore(end)) {} }, ), ], ); } Widget _buildBackupSection() { return SettingsGroup( title: t.settings.backup, children: [ SettingNavigationTile( focusNode: _focusTracker.get(_kExportSettings), icon: Symbols.upload_rounded, title: t.settings.exportSettings, subtitle: t.settings.exportSettingsDescription, onTap: _handleExportSettings, ), SettingNavigationTile( focusNode: _focusTracker.get(_kImportSettings), icon: Symbols.download_rounded, title: t.settings.importSettings, subtitle: t.settings.importSettingsDescription, onTap: _showImportSettingsDialog, ), // TODO: Cloud settings sync/backup (#1795, #1979) goes here once a // sync backend exists (iCloud on Apple platforms, or the relay). ], ); } 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) { return SettingsGroup( title: t.settings.updates, children: [ SettingNavigationTile( focusNode: _focusTracker.get(_kCheckForUpdates), icon: Symbols.system_update_rounded, title: t.settings.checkForUpdates, onTap: () => UpdateService.checkForUpdatesNative(inBackground: false), ), _buildAutoCheckUpdatesOnStartupTile(), ], ); } final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true; return SettingsGroup( title: t.settings.updates, children: [ FocusableListTile( focusNode: _focusTracker.get(_kCheckForUpdates), leading: AppIcon( hasUpdate ? Symbols.system_update_rounded : Symbols.check_circle_rounded, fill: 1, color: hasUpdate ? Colors.orange : null, ), title: Text(hasUpdate ? t.settings.updateAvailable : t.settings.checkForUpdates), subtitle: hasUpdate ? Text(t.update.versionAvailable(version: _updateInfo!['latestVersion'])) : null, trailing: _isCheckingForUpdate ? const LoadingIndicatorBox(size: 24) : const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: _isCheckingForUpdate ? null : () { if (hasUpdate) { _showUpdateDialog(); } else { _checkForUpdates(); } }, ), _buildAutoCheckUpdatesOnStartupTile(), ], ); } Future _showDownloadLocationDialog() async { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); await showScopedDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(t.settings.downloads), content: Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ Text(t.settings.downloadLocationDescription), const SizedBox(height: 16), FutureBuilder( future: storageService.getCurrentDownloadPathDisplay(), builder: (context, snapshot) { return Text( t.settings.currentPath(path: snapshot.data ?? '...'), style: Theme.of(context).textTheme.bodySmall, ); }, ), ], ), actions: [ if (isCustom) DialogActionButton( onPressed: () async { // Run the async work first, then pop — popping first leaves // setState inside _resetDownloadLocation racing against the // already-dismissed dialog (and any re-opened instance). await _resetDownloadLocation(); if (dialogContext.mounted) Navigator.pop(dialogContext); }, label: t.settings.resetToDefault, ), DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), DialogActionButton( onPressed: () async { final changed = await _selectDownloadLocation(); if (changed && dialogContext.mounted) Navigator.pop(dialogContext); }, label: t.settings.selectFolder, isPrimary: true, ), ], ), ); } Future _selectDownloadLocation() async { final changed = await guardSettingsOperation( context, operation: 'Download directory selection', body: () async { String? selectedPath; String pathType = 'file'; if (Platform.isAndroid) { final safStorage = SafStorageService.instance; if (!safStorage.supportsDirectoryPicker) { showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable); return false; } selectedPath = await safStorage.pickDirectory(); if (!mounted) return false; if (selectedPath != null) pathType = 'saf'; } else { selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); if (!mounted) return false; } if (selectedPath == null) return false; if (pathType == 'file') { final dir = Directory(selectedPath); final writableChecker = widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable; final isWritable = await writableChecker(dir); if (!mounted) return false; if (!isWritable) { showErrorSnackBar(context, t.settings.downloadLocationInvalid); return false; } } await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); if (!mounted) return false; // ignore: no-empty-block - setState triggers rebuild to reflect new download path setState(() {}); showSuccessSnackBar(context, t.settings.downloadLocationChanged); return true; }, ); return changed ?? false; } Future _resetDownloadLocation() async { await context.read().resetDownloadLocation(); if (mounted) { // ignore: no-empty-block - setState triggers rebuild to reflect reset path setState(() {}); showAppSnackBar(context, t.settings.downloadLocationReset); } } Future _showRelayUrlDialog() async { await showScopedDialog( context: context, builder: (_) => _RelayUrlDialog(settingsService: _settingsService), ); } Future _showClearImageCacheDialog() async { final confirmed = await showConfirmDialog( context, title: t.settings.clearImageCache, message: t.settings.clearImageCacheDescription, confirmText: t.common.clear, ); if (!confirmed) return; await _settingsService.clearImageCache(); if (mounted) showSuccessSnackBar(context, t.settings.clearImageCacheSuccess); } Future _showResetSettingsDialog() async { final confirmed = await showConfirmDialog( context, title: t.settings.resetSettings, message: t.settings.resetSettingsDescription, confirmText: t.common.reset, isDestructive: true, ); if (!mounted || !confirmed) return; await context.read().resetDownloadLocation(); await _settingsService.resetAllSettings(); await _keyboardService?.resetToDefaults(); if (mounted) showSuccessSnackBar(context, t.settings.resetSettingsSuccess); } Future _handleExportSettings() async { await guardSettingsOperation( context, operation: 'Settings export', body: () async { final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); if (!mounted || path == null) return; showSuccessSnackBar(context, t.settings.exportSettingsSuccess); }, ); } Future _showImportSettingsDialog() async { final confirmed = await showConfirmDialog( context, title: t.settings.importSettings, message: t.settings.importSettingsConfirm, confirmText: t.settings.importSettings, ); if (!mounted || !confirmed) return; await _handleImportSettings(); } Future _handleImportSettings() async { await guardSettingsOperation( context, operation: 'Settings import', body: () async { // The two typed import failures carry their own message, so they are // handled here instead of falling through to the generic guard. try { final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); if (!mounted) return; if (result == null) return; // user cancelled file picker final themeProvider = context.read(); final hiddenLibrariesProvider = context.read(); final librariesProvider = context.read(); // 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(), hiddenLibrariesProvider.refresh(), if (_keyboardService != null) _keyboardService!.refreshFromStorage(), ]); unawaited(librariesProvider.refresh()); if (!mounted) return; showSuccessSnackBar(context, t.settings.importSettingsSuccess); } on NoUserSignedInException { if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); } on InvalidExportFileException { if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile); } }, ); } Future _checkForUpdates() async { setState(() => _isCheckingForUpdate = true); try { final updateInfo = await UpdateService.checkForUpdates(); if (mounted) { setState(() { _updateInfo = updateInfo; _isCheckingForUpdate = false; }); if (updateInfo == null || updateInfo['hasUpdate'] != true) { showAppSnackBar(context, t.update.latestVersion); } } } catch (e) { if (mounted) { setState(() => _isCheckingForUpdate = false); showErrorSnackBar(context, t.update.checkFailed); } } } void _showUpdateDialog() { final updateInfo = _updateInfo; if (updateInfo == null) return; unawaited( showUpdateAvailableDialog(context, updateInfo, title: t.settings.updateAvailable, dismissLabel: t.common.close), ); } } class _RelayUrlDialog extends StatefulWidget { final settings.SettingsService settingsService; const _RelayUrlDialog({required this.settingsService}); @override State<_RelayUrlDialog> createState() => _RelayUrlDialogState(); } class _RelayUrlDialogState extends State<_RelayUrlDialog> { late final TextEditingController _controller; final _saveFocusNode = FocusNode(debugLabel: 'WatchTogetherRelaySave'); bool _relayUrlInvalid = false; @override void initState() { super.initState(); _controller = TextEditingController( text: widget.settingsService.read(settings.SettingsService.customRelayUrl) ?? '', ); } @override void dispose() { _saveFocusNode.dispose(); _controller.dispose(); super.dispose(); } Future _reset() async { _controller.clear(); await widget.settingsService.write(settings.SettingsService.customRelayUrl, null); if (mounted) Navigator.pop(context); } Future _save() async { final value = _controller.text; if (value.trim().isEmpty) { await widget.settingsService.write(settings.SettingsService.customRelayUrl, null); if (mounted) Navigator.pop(context); return; } final endpoint = WatchTogetherRelayEndpoint.tryParseCustom(value); if (endpoint == null) { setState(() => _relayUrlInvalid = true); return; } await widget.settingsService.write(settings.SettingsService.customRelayUrl, endpoint.canonicalBaseUrl); if (mounted) Navigator.pop(context); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(t.settings.watchTogetherRelay), content: FocusableTextField( controller: _controller, decoration: InputDecoration( labelText: t.common.url, hintText: t.settings.watchTogetherRelayHint, errorText: _relayUrlInvalid ? t.settings.watchTogetherRelayInvalid : null, ), autofocus: true, textInputAction: TextInputAction.done, onChanged: (_) { if (_relayUrlInvalid) { setState(() => _relayUrlInvalid = false); } }, onEditingComplete: () => _saveFocusNode.requestFocus(), onNavigateDown: _saveFocusNode.requestFocus, ), actions: [ DialogActionButton(onPressed: _reset, label: t.settings.resetToDefault), DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel), DialogActionButton(focusNode: _saveFocusNode, onPressed: _save, label: t.common.save), ], ); } }