Files
plezy/lib/screens/settings/settings_screen.dart
T
edde746andGitHub ae555ed5d7 feat(settings): account preferences, stored on the server instead of the device (#2112)
Preferences that belong to the media-server account had nowhere to live in Plezy. Jellyfin keeps a user's audio/subtitle language, subtitle mode and several library options in `UserConfiguration`; plex.tv keeps the same language choices plus watched indicators and review visibility on the account. Plezy read four of those fields for auto-track selection and could never show or change any of them.

Adds an Account preferences section under Connections. One account edits in place; several show a picker first, scoped to the active profile's own connections so a managed Plex Home user never edits the owner's record. Every row is gated on what the backend can actually store.

It also moves "rewatching in Next Up" onto the account. There is no `UserConfiguration` field for it, but the per-user `DisplayPreferences` store is keyed `(userId, displayPreferencesId, client)` with no device component, so the switch goes there and follows the account.

`AccountRef` keys by account, not `clientScopeId`: MediaBrowser is `{machineId}/{userId}`, Plex is (account, Home user). Writes are patch-shaped because both backends replace whole objects — Jellyfin's `POST /Users/{id}/Configuration` and its `DisplayPreferences` row both reset omitted fields, so each write re-reads, merges only the patched keys, and posts back. Plex takes its changes as query parameters with an empty body, and its `experience` blob and the PMS `/accounts/1` mirror are deliberately untouched.

`AccountPreferencesController` owns a single repository above the profile session, so changing a language in settings reaches the next playback without a restart. Emby is gated out of rewatching through `MediaBrowserDialect.supportsNextUpRewatching`.

close #1910
2026-08-25 08:42:48 +02:00

962 lines
36 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/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/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 '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';
static const _kAccountPreferences = 'account_preferences';
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),
]),
),
const SliverSystemBottomInset(),
],
),
),
);
}
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),
if (context.select<AccountPreferencesController, bool>((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<AccountPreferencesController, List<AccountPreferenceAccount>>((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<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: 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),
],
);
}
}