Files
plezy/lib/screens/settings/settings_screen.dart
T
edde746 c48cbf7059 fix(settings): dismiss Manage Libraries without leaving Settings
On phone layouts main_screen pushes SettingsScreen as its own route, and
that route carried no OverlaySheetHost. showAdaptive could not find one
from the tile's context, so Manage Libraries fell back to
showModalBottomSheet. The sheet also owns a focused Back handler, so a
single Android Back arrived twice — once as a key event, once as
popRoute — and the two route-based paths raced, tearing down Settings
along with the sheet.

Installs one route-local host when no enclosing host exists, and opens
the sheet from a context below it. OverlaySheetHost then holds the route
while a sheet is open and deduplicates the key path, so one Back closes
only the sheet.
2026-07-26 23:05:53 +02:00

937 lines
34 KiB
Dart

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/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/keyboard_shortcuts_service.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 '../../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 'appearance_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<bool> Function(Directory directory)? downloadDirectoryWritableChecker;
@visibleForTesting
final Future<String?> Function()? settingsExporter;
@visibleForTesting
final Future<ImportResult?> Function()? settingsImporter;
@visibleForTesting
final BackgroundWorkDiagnosticsService? backgroundWorkDiagnosticsService;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, MountedSetStateMixin {
BackgroundWorkDiagnosticsService get _backgroundWorkDiagnostics =>
widget.backgroundWorkDiagnosticsService ?? BackgroundWorkDiagnosticsService.instance;
late final FocusMemoryTracker _focusTracker;
// Focus tracking keys
static const _kDonate = 'donate';
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 _kCrashReporting = 'crash_reporting';
static const _kDebugLogging = 'debug_logging';
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';
KeyboardShortcutsService? _keyboardService;
late final bool _keyboardShortcutsSupported = KeyboardShortcutsService.isPlatformSupported();
// Update checking state
bool _isCheckingForUpdate = false;
Map<String, dynamic>? _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 : _kAppearance);
}
}
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<LibrariesProvider, bool>((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(),
_buildAppearanceTile(),
_buildPlaybackTile(),
if (hasLibraries) _buildManageLibrariesTile(sheetContext),
_buildServicesTile(),
],
),
_buildConnectionsSection(sheetContext),
if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(),
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()],
_buildAdvancedSection(),
if (UpdateService.isUpdateCheckEnabled) ...[_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),
]),
),
],
),
),
);
}
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<ThemeProvider>(
builder: (context, themeProvider, _) => SettingValueBuilder<int>(
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<SeerrAccountProvider>(
builder: (context, seerr, _) {
final connectedNames = <String>[
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<ActiveProfileProvider, Profile?>((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<ActiveProfileProvider>().active;
Navigator.push(context, MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: active)));
},
),
_buildProfilesTile(context),
],
);
}
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<ActiveProfileProvider, int>((p) => p.profiles.length);
final activeName = context.select<ActiveProfileProvider, String?>((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<String>(
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(),
],
);
}
/// 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);
},
);
},
);
}
Widget _buildKeyboardShortcutsSection() {
if (_keyboardService == null) return const SizedBox.shrink();
return SettingsGroup(
title: t.settings.keyboardShortcuts,
children: [
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,
),
],
);
}
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,
),
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,
),
],
);
}
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<void> _showDownloadLocationDialog() async {
final storageService = DownloadStorageService.instance;
final isCustom = storageService.isUsingCustomPath();
await showScopedDialog<void>(
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<String>(
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<bool> _selectDownloadLocation() async {
final changed = await guardSettingsOperation<bool, DownloadStorageException>(
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<DownloadProvider>().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<void> _resetDownloadLocation() async {
await context.read<DownloadProvider>().resetDownloadLocation();
if (mounted) {
// ignore: no-empty-block - setState triggers rebuild to reflect reset path
setState(() {});
showAppSnackBar(context, t.settings.downloadLocationReset);
}
}
Future<void> _showRelayUrlDialog() async {
await showScopedDialog<void>(
context: context,
builder: (_) => _RelayUrlDialog(settingsService: _settingsService),
);
}
Future<void> _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<void> _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<DownloadProvider>().resetDownloadLocation();
await _settingsService.resetAllSettings();
await _keyboardService?.resetToDefaults();
if (mounted) showSuccessSnackBar(context, t.settings.resetSettingsSuccess);
}
Future<void> _handleExportSettings() async {
await guardSettingsOperation<void, SettingsExportException>(
context,
operation: 'Settings export',
body: () async {
final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)();
if (!mounted || path == null) return;
showSuccessSnackBar(context, t.settings.exportSettingsSuccess);
},
);
}
Future<void> _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<void> _handleImportSettings() async {
await guardSettingsOperation<void, SettingsExportException>(
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<ThemeProvider>();
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
final librariesProvider = context.read<LibrariesProvider>();
// 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<void> _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<void> _reset() async {
_controller.clear();
await widget.settingsService.write(settings.SettingsService.customRelayUrl, null);
if (mounted) Navigator.pop(context);
}
Future<void> _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: '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),
],
);
}
}