Settings rows carried their own platform-conditional typography and density, so on desktop and TV they rendered a 16px title, 14px subtitle and 80px row while every other row in the app — the Focusable*ListTile defaults plus ThemeData.listTileTheme's `dense: true` — renders 13/12 in 61px. Drop the overrides instead of re-tuning them: the tile defaults already encode the app's row style, and the explicit title styles were redundant under a dense ListTile (they also masked the disabled/selected title color). settingsOptionTitleStyle now only serves group children that are not ListTiles, and matches the dense title unconditionally. SettingsGroup hands its children that same compact density, so the plain ListTiles used as non-interactive info rows stop standing 11px taller than their interactive siblings.
921 lines
33 KiB
Dart
921 lines
33 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/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);
|
|
|
|
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<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() {
|
|
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() {
|
|
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(),
|
|
],
|
|
);
|
|
}
|
|
|
|
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<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),
|
|
],
|
|
);
|
|
}
|
|
}
|