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 '../../providers/trackers_provider.dart'; import '../../providers/trakt_account_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart' as settings; 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/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.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 '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key, this.downloadDirectoryWritableChecker}); @visibleForTesting final Future Function(Directory directory)? downloadDirectoryWritableChecker; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State with FocusableTab, MountedSetStateMixin { 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 _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 _kClearCache = 'clear_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? _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((p) => p.libraries.isNotEmpty); 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(), _buildServicesTile(), ], ), _buildConnectionsSection(), 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( 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() { return SettingNavigationTile( focusNode: _focusTracker.get(_kManageLibraries), icon: Symbols.video_library_rounded, title: t.libraries.manageLibraries, subtitle: t.settings.manageLibrariesDescription, onTap: () => showLibraryManagementSheet(context), ); } Widget _buildServicesTile() { return Consumer3( builder: (context, trakt, trackers, seerr, _) { final connectedNames = [ if (trakt.isConnected) t.trakt.title, if (trackers.isMalConnected) t.services.names.mal, if (trackers.isAnilistConnected) t.services.names.anilist, if (trackers.isSimklConnected) t.services.names.simkl, 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() { 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(), ], ); } Widget _buildProfilesTile() { // 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, style: settingsOptionTitleStyle(context), ), subtitle: Text(currentPath, maxLines: 2, overflow: .ellipsis), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showDownloadLocationDialog(), dense: settingsRowDense(), visualDensity: settingsRowVisualDensity(), ); }, ), 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, ), ], ); } 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(_kClearCache), icon: Symbols.cleaning_services_rounded, title: t.settings.clearCache, subtitle: t.settings.clearCacheDescription, onTap: () => _showClearCacheDialog(), ), 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, style: settingsOptionTitleStyle(context), ), 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(); } }, dense: settingsRowDense(), visualDensity: settingsRowVisualDensity(), ), _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 { await _selectDownloadLocation(); if (dialogContext.mounted) Navigator.pop(dialogContext); }, label: t.settings.selectFolder, isPrimary: true, ), ], ), ); } Future _selectDownloadLocation() async { try { String? selectedPath; String pathType = 'file'; if (Platform.isAndroid) { final safService = SafStorageService.instance; selectedPath = await safService.pickDirectory(); if (selectedPath != null) { pathType = 'saf'; } else if (PlatformDetector.isTV()) { if (mounted) { showErrorSnackBar(context, t.settings.downloadLocationSelectError); } return; } } else { final result = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); selectedPath = result; } if (selectedPath != null) { if (pathType == 'file') { final dir = Directory(selectedPath); final isWritable = await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)( dir, ); if (!isWritable) { if (mounted) { showErrorSnackBar(context, t.settings.downloadLocationInvalid); } return; } } if (!mounted) return; await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); if (mounted) { // ignore: no-empty-block - setState triggers rebuild to reflect new download path setState(() {}); showSuccessSnackBar(context, t.settings.downloadLocationChanged); } } } catch (e) { if (mounted) { showErrorSnackBar(context, t.settings.downloadLocationSelectError); } } } 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 _showClearCacheDialog() async { final confirmed = await showConfirmDialog( context, title: t.settings.clearCache, message: t.settings.clearCacheDescription, confirmText: t.common.clear, ); if (!confirmed) return; await _settingsService.clearCache(); if (mounted) showSuccessSnackBar(context, t.settings.clearCacheSuccess); } 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 { try { final path = await SettingsExportService.exportToFile(); if (!mounted) return; if (path == null) return; // user cancelled showSuccessSnackBar(context, t.settings.exportSettingsSuccess); } on SettingsExportException { if (mounted) showErrorSnackBar(context, t.settings.exportSettingsFailed); } catch (_) { if (mounted) showErrorSnackBar(context, t.settings.exportSettingsFailed); } } Future _showImportSettingsDialog() async { final confirmed = await showConfirmDialog( context, title: t.settings.importSettings, message: t.settings.importSettingsConfirm, confirmText: t.settings.importSettings, ); if (!confirmed) return; await _handleImportSettings(); } Future _handleImportSettings() async { // Capture providers before any awaits so we don't reach through `context` // after the widget may have been unmounted. final themeProvider = context.read(); final hiddenLibrariesProvider = context.read(); final librariesProvider = context.read(); try { final result = await SettingsExportService.importFromFile(); if (!mounted) return; if (result == null) return; // user cancelled file picker // Import wrote directly to SharedPreferences, bypassing `write`. Push // fresh values into active listenables before providers re-read settings. _settingsService.refreshListenables(); unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); await Future.wait([ themeProvider.reload(), 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); } on SettingsExportException { if (mounted) showErrorSnackBar(context, t.settings.importSettingsFailed); } catch (_) { if (mounted) showErrorSnackBar(context, t.settings.importSettingsFailed); } } 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'); @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 trimmed = _controller.text.trim(); await widget.settingsService.write(settings.SettingsService.customRelayUrl, trimmed.isEmpty ? null : trimmed); 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), autofocus: true, textInputAction: TextInputAction.done, onEditingComplete: () => _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), ], ); } }