Files
plezy/lib/screens/settings/settings_screen.dart
T
edde746 944a8d89f5 feat(windows): package for the Microsoft Store as an MSIX bundle
The Store's unpackaged EXE path would require Authenticode-signing the
installer and every PE file inside it. MSIX submissions are re-signed by the
Store instead, so this route needs no code-signing certificate. build-msix.ps1
mirrors build-installer.ps1 and consumes the same per-architecture build
artifacts, leaving the installer, portable archives and WinSparkle appcast
untouched.

One template generates the manifest for both architectures, carrying the
identity reserved in Partner Center. check_windows_msix.py recomputes the
package family name from the publisher DN, so a mistyped identity fails CI
rather than a submission, and it parses the script rather than running it
because root CI is Linux. Qualified logo assets are indexed into
resources.pri; without the altform-unplated variants the shell draws the
taskbar icon on an accent-coloured plate.

PlatformDetector.isPackagedInstall gates the in-app updater and the Liberapay
tile, which the read-only package directory and Store commerce policy
respectively rule out. Gating at runtime keeps one Windows build feeding both
the installer and the Store package.
2026-07-30 20:03:04 +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.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),
]),
),
],
),
),
);
}
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),
],
);
}
}