Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is 36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16 disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on API 35+ the window is edge-to-edge from the first frame and MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation. MainScreen's phone layout hides that. It supplies a bottomNavigationBar and never sets extendBody, so Flutter's Scaffold strips padding.bottom from the body MediaQuery and every tab is already safe. Routes pushed on the profile navigator are full-screen siblings of MainScreen with no bottom bar, so they receive the untouched inset and nothing consumes it - the last settings card and the final log lines render under the back, home, and recents buttons. Three shared hosts own most of those routes, so the inset is consumed there: FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing SliverSystemBottomInset, and the four screens that build their own Scaffold around a CustomScrollView append it directly. The new widget codifies the convention this repository had already written down but open-coded - insets baked into the scroll content rather than a SafeArea around the scroll view - so content still paints under the bar while the scroll extent grows enough to bring the last row above it. It reads padding from its own context and collapses to zero height wherever the inset is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside MainScreen's tab bodies. No platform branching, and it stacks additively with the music detail screens' existing mini-player spacers, which is correct because the mini-player itself floats above the navigation bar on a pushed route. Scroll views that are not sliver lists take the inset in their own padding: the companion remote's ListView, the auth screen's scroll container, and the two SliverFillRemaining sign-in forms, whose children size themselves from the extent remaining before them and so cannot be helped by a trailing sliver. The logs empty state is left alone for the same reason inverted - it already fills the viewport, and a trailing inset would only add scroll slack. Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation: Settings, Logs, and Video Playback all end clear of the bar. close #1766
939 lines
34 KiB
Dart
939 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 '../../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 '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),
|
|
]),
|
|
),
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|