mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 12:48:03 +02:00
3278e32711
Prefer the in-app browser for signed-session verification challenges, normalize invalid saved modes to the new default, and keep the help dialog modal until the user explicitly dismisses it.
519 lines
18 KiB
Dart
519 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
|
|
class AppSettingsPage extends ConsumerWidget {
|
|
const AppSettingsPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final settings = ref.watch(settingsProvider);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final topPadding = normalizedHeaderTopPadding(context);
|
|
|
|
return PopScope(
|
|
canPop: true,
|
|
child: Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
SliverAppBar(
|
|
expandedHeight: 120 + topPadding,
|
|
collapsedHeight: kToolbarHeight,
|
|
floating: false,
|
|
pinned: true,
|
|
backgroundColor: colorScheme.surface,
|
|
surfaceTintColor: Colors.transparent,
|
|
leading: IconButton(
|
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
flexibleSpace: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxHeight = 120 + topPadding;
|
|
final minHeight = kToolbarHeight + topPadding;
|
|
final expandRatio =
|
|
((constraints.maxHeight - minHeight) /
|
|
(maxHeight - minHeight))
|
|
.clamp(0.0, 1.0);
|
|
final leftPadding = 56 - (32 * expandRatio);
|
|
return FlexibleSpaceBar(
|
|
expandedTitleScale: 1.0,
|
|
titlePadding: EdgeInsets.only(
|
|
left: leftPadding,
|
|
bottom: 16,
|
|
),
|
|
title: Text(
|
|
context.l10n.settingsApp,
|
|
style: TextStyle(
|
|
fontSize: 20 + (8 * expandRatio),
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsSwitchItem(
|
|
icon: Icons.extension,
|
|
title: context.l10n.optionsExtensionStore,
|
|
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
|
value: settings.showExtensionStore,
|
|
onChanged: (v) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setShowExtensionStore(v),
|
|
),
|
|
_VerificationBrowserModeSelector(
|
|
currentMode: settings.extensionVerificationBrowserMode,
|
|
onChanged: (mode) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setExtensionVerificationBrowserMode(mode),
|
|
),
|
|
SettingsSwitchItem(
|
|
icon: Icons.system_update,
|
|
title: context.l10n.optionsCheckUpdates,
|
|
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
|
|
value: settings.checkForUpdates,
|
|
onChanged: (v) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setCheckForUpdates(v),
|
|
showDivider: settings.checkForUpdates,
|
|
),
|
|
if (settings.checkForUpdates)
|
|
_UpdateChannelSelector(
|
|
currentChannel: settings.updateChannel,
|
|
onChanged: (v) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setUpdateChannel(v),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsItem(
|
|
icon: Icons.cleaning_services_outlined,
|
|
title: context.l10n.cleanupOrphanedDownloads,
|
|
subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle,
|
|
onTap: () => _cleanupOrphanedDownloads(context, ref),
|
|
),
|
|
SettingsItem(
|
|
icon: Icons.delete_forever,
|
|
title: context.l10n.optionsClearHistory,
|
|
subtitle: context.l10n.optionsClearHistorySubtitle,
|
|
onTap: () =>
|
|
_showClearHistoryDialog(context, ref, colorScheme),
|
|
),
|
|
SettingsSwitchItem(
|
|
icon: Icons.history_toggle_off_outlined,
|
|
title: context.l10n.settingsSaveDownloadHistory,
|
|
subtitle: context.l10n.settingsSaveDownloadHistorySubtitle,
|
|
value: settings.saveDownloadHistory,
|
|
onChanged: (enabled) {
|
|
if (enabled) {
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setSaveDownloadHistory(true);
|
|
return;
|
|
}
|
|
|
|
final hasHistory = ref
|
|
.read(downloadHistoryProvider)
|
|
.items
|
|
.isNotEmpty;
|
|
if (hasHistory) {
|
|
_showDisableHistoryDialog(context, ref, colorScheme);
|
|
return;
|
|
}
|
|
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setSaveDownloadHistory(false);
|
|
},
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsSwitchItem(
|
|
icon: Icons.bug_report,
|
|
title: context.l10n.optionsDetailedLogging,
|
|
subtitle: settings.enableLogging
|
|
? context.l10n.optionsDetailedLoggingOn
|
|
: context.l10n.optionsDetailedLoggingOff,
|
|
value: settings.enableLogging,
|
|
onChanged: (v) =>
|
|
ref.read(settingsProvider.notifier).setEnableLogging(v),
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDisableHistoryDialog(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.dialogDisableHistoryTitle),
|
|
content: Text(context.l10n.dialogDisableHistoryMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
ref.read(settingsProvider.notifier).setSaveDownloadHistory(false);
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarHistoryCleared)),
|
|
);
|
|
},
|
|
child: Text(
|
|
context.l10n.dialogDisableAndClear,
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showClearHistoryDialog(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.dialogClearHistoryTitle),
|
|
content: Text(context.l10n.dialogClearHistoryMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarHistoryCleared)),
|
|
);
|
|
},
|
|
child: Text(
|
|
context.l10n.dialogClear,
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _cleanupOrphanedDownloads(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
) async {
|
|
showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
content: Row(
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(width: 16),
|
|
Text(context.l10n.cleanupOrphanedDownloads),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
try {
|
|
final removed = await ref
|
|
.read(downloadHistoryProvider.notifier)
|
|
.cleanupOrphanedDownloads();
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
removed > 0
|
|
? context.l10n.cleanupOrphanedDownloadsResult(removed)
|
|
: context.l10n.cleanupOrphanedDownloadsNone,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class _UpdateChannelSelector extends StatelessWidget {
|
|
final String currentChannel;
|
|
final ValueChanged<String> onChanged;
|
|
const _UpdateChannelSelector({
|
|
required this.currentChannel,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.new_releases,
|
|
color: colorScheme.onSurfaceVariant,
|
|
size: 24,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.optionsUpdateChannel,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
currentChannel == 'preview'
|
|
? context.l10n.optionsUpdateChannelPreview
|
|
: context.l10n.optionsUpdateChannelStable,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
_ChannelChip(
|
|
label: context.l10n.channelStable,
|
|
isSelected: currentChannel == 'stable',
|
|
onTap: () => onChanged('stable'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_ChannelChip(
|
|
label: context.l10n.channelPreview,
|
|
isSelected: currentChannel == 'preview',
|
|
onTap: () => onChanged('preview'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 16,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.optionsUpdateChannelWarning,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VerificationBrowserModeSelector extends StatelessWidget {
|
|
final String currentMode;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
const _VerificationBrowserModeSelector({
|
|
required this.currentMode,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final normalizedMode = currentMode == 'in_app_first'
|
|
? 'in_app_first'
|
|
: currentMode == 'external_first'
|
|
? 'external_first'
|
|
: 'in_app_first';
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.open_in_browser,
|
|
color: colorScheme.onSurfaceVariant,
|
|
size: 24,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.extensionVerificationBrowserTitle,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
normalizedMode == 'external_first'
|
|
? context
|
|
.l10n
|
|
.extensionVerificationBrowserSubtitleExternal
|
|
: context
|
|
.l10n
|
|
.extensionVerificationBrowserSubtitleInApp,
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
_ChannelChip(
|
|
label: context.l10n.extensionVerificationBrowserExternal,
|
|
isSelected: normalizedMode == 'external_first',
|
|
onTap: () => onChanged('external_first'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_ChannelChip(
|
|
label: context.l10n.extensionVerificationBrowserInApp,
|
|
isSelected: normalizedMode == 'in_app_first',
|
|
onTap: () => onChanged('in_app_first'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
indent: 56,
|
|
endIndent: 20,
|
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChannelChip extends StatelessWidget {
|
|
final String label;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
const _ChannelChip({
|
|
required this.label,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final unselectedColor = isDark
|
|
? Color.alphaBlend(
|
|
Colors.white.withValues(alpha: 0.05),
|
|
colorScheme.surface,
|
|
)
|
|
: colorScheme.surfaceContainerHigh;
|
|
return Expanded(
|
|
child: Material(
|
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
|
color: isSelected
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|