refactor(settings): unify settings components

This commit is contained in:
zarzet
2026-07-29 17:11:34 +07:00
parent d765320f10
commit 309777cc71
6 changed files with 432 additions and 573 deletions
+10 -53
View File
@@ -4,7 +4,7 @@ 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/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class AppSettingsPage extends ConsumerWidget {
const AppSettingsPage({super.key});
@@ -19,7 +19,7 @@ class AppSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
SettingsSliverAppBar(title: context.l10n.settingsApp),
AppSliverHeader.page(title: context.l10n.settingsApp),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionApp),
@@ -305,13 +305,15 @@ class _UpdateChannelSelector extends StatelessWidget {
const SizedBox(height: 16),
Row(
children: [
_ChannelChip(
SettingsChoiceChip(
expand: true,
label: context.l10n.channelStable,
isSelected: currentChannel == 'stable',
onTap: () => onChanged('stable'),
),
const SizedBox(width: 8),
_ChannelChip(
SettingsChoiceChip(
expand: true,
label: context.l10n.channelPreview,
isSelected: currentChannel == 'preview',
onTap: () => onChanged('preview'),
@@ -405,13 +407,15 @@ class _VerificationBrowserModeSelector extends StatelessWidget {
const SizedBox(height: 16),
Row(
children: [
_ChannelChip(
SettingsChoiceChip(
expand: true,
label: context.l10n.extensionVerificationBrowserExternal,
isSelected: normalizedMode == 'external_first',
onTap: () => onChanged('external_first'),
),
const SizedBox(width: 8),
_ChannelChip(
SettingsChoiceChip(
expand: true,
label: context.l10n.extensionVerificationBrowserInApp,
isSelected: normalizedMode == 'in_app_first',
onTap: () => onChanged('in_app_first'),
@@ -432,50 +436,3 @@ class _VerificationBrowserModeSelector extends StatelessWidget {
);
}
}
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,
),
),
),
),
),
),
);
}
}
@@ -6,7 +6,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class AppearanceSettingsPage extends ConsumerWidget {
const AppearanceSettingsPage({super.key});
@@ -21,7 +21,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
child: Scaffold(
body: CustomScrollView(
slivers: [
SettingsSliverAppBar(title: context.l10n.appearanceTitle),
AppSliverHeader.page(title: context.l10n.appearanceTitle),
SliverToBoxAdapter(
child: Padding(
@@ -456,21 +456,27 @@ class _ThemeModeSelector extends StatelessWidget {
padding: const EdgeInsets.all(12),
child: Row(
children: [
_ThemeModeChip(
SettingsChoiceChip(
expand: true,
layout: SettingsChipLayout.column,
icon: Icons.brightness_auto,
label: context.l10n.appearanceThemeSystem,
isSelected: currentMode == ThemeMode.system,
onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
SettingsChoiceChip(
expand: true,
layout: SettingsChipLayout.column,
icon: Icons.light_mode,
label: context.l10n.appearanceThemeLight,
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
SettingsChoiceChip(
expand: true,
layout: SettingsChipLayout.column,
icon: Icons.dark_mode,
label: context.l10n.appearanceThemeDark,
isSelected: currentMode == ThemeMode.dark,
@@ -482,84 +488,6 @@ class _ThemeModeSelector extends StatelessWidget {
}
}
class _ThemeModeChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ThemeModeChip({
required this.icon,
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,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected
? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
);
}
}
class _HistoryViewSelector extends StatelessWidget {
final String currentMode;
final ValueChanged<String> onChanged;
@@ -587,14 +515,18 @@ class _HistoryViewSelector extends StatelessWidget {
),
Row(
children: [
_ViewModeChip(
SettingsChoiceChip(
expand: true,
layout: SettingsChipLayout.column,
icon: Icons.view_list,
label: context.l10n.appearanceHistoryViewList,
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
SettingsChoiceChip(
expand: true,
layout: SettingsChipLayout.column,
icon: Icons.grid_view,
label: context.l10n.appearanceHistoryViewGrid,
isSelected: currentMode == 'grid',
@@ -608,84 +540,6 @@ class _HistoryViewSelector extends StatelessWidget {
}
}
class _ViewModeChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ViewModeChip({
required this.icon,
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,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded(
child: Container(
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected
? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
);
}
}
class _LanguageSelector extends StatelessWidget {
final String currentLocale;
final ValueChanged<String> onChanged;
@@ -744,9 +598,6 @@ class _LanguageSelector extends StatelessWidget {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
+137 -211
View File
@@ -7,7 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget {
const DownloadSettingsPage({super.key});
@@ -55,7 +55,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
SettingsSliverAppBar(title: context.l10n.settingsDownload),
AppSliverHeader.page(title: context.l10n.settingsDownload),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionService),
@@ -411,9 +411,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -510,9 +507,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -581,9 +575,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -835,58 +826,140 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
builder: (context) => _RegionPickerSheet(
regions: regions,
selected: normalizedCurrent,
countryNameOf: (context, code) => _regionCountryName(context, code),
onSelected: (code) {
ref.read(settingsProvider.notifier).setSongLinkRegion(code);
Navigator.pop(context);
},
),
builder: (context) => SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.7,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadSongLinkRegion,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
);
}
}
/// Searchable region list.
///
/// The picker previously rendered ~190 ISO codes as a flat list with no filter,
/// so finding a country meant scrolling blind — only a handful of codes have a
/// localized name to recognize them by.
class _RegionPickerSheet extends StatefulWidget {
const _RegionPickerSheet({
required this.regions,
required this.selected,
required this.countryNameOf,
required this.onSelected,
});
final List<String> regions;
final String selected;
final String Function(BuildContext context, String code) countryNameOf;
final ValueChanged<String> onSelected;
@override
State<_RegionPickerSheet> createState() => _RegionPickerSheetState();
}
class _RegionPickerSheetState extends State<_RegionPickerSheet> {
final TextEditingController _searchController = TextEditingController();
String _query = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final query = _query.trim().toLowerCase();
final matches = query.isEmpty
? widget.regions
: widget.regions.where((code) {
if (code.toLowerCase().contains(query)) return true;
return widget
.countryNameOf(context, code)
.toLowerCase()
.contains(query);
}).toList();
return SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.7,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadSongLinkRegion,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.downloadSongLinkRegionDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadSongLinkRegionDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
controller: _searchController,
autofocus: false,
textInputAction: TextInputAction.search,
onChanged: (value) => setState(() => _query = value),
decoration: InputDecoration(
hintText: MaterialLocalizations.of(context).searchFieldLabel,
prefixIcon: const Icon(Icons.search),
suffixIcon: _query.isEmpty
? null
: IconButton(
tooltip: context.l10n.dialogClear,
icon: const Icon(Icons.close),
onPressed: () {
_searchController.clear();
setState(() => _query = '');
},
),
),
),
Expanded(
child: ListView.builder(
itemCount: regions.length,
itemBuilder: (context, index) {
final code = regions[index];
final isSelected = code == normalizedCurrent;
final countryName = _regionCountryName(context, code);
return ListTile(
title: Text(code),
subtitle: countryName != code ? Text(countryName) : null,
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setSongLinkRegion(code);
Navigator.pop(context);
),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
context.l10n.searchEmptyResultSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: matches.length,
itemBuilder: (context, index) {
final code = matches[index];
final isSelected = code == widget.selected;
final countryName = widget.countryNameOf(context, code);
return ListTile(
title: Text(countryName != code ? countryName : code),
subtitle: countryName != code ? Text(code) : null,
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () => widget.onSelected(code),
);
},
);
},
),
),
],
),
),
),
],
),
),
);
@@ -985,7 +1058,8 @@ class _ServiceSelector extends ConsumerWidget {
for (final extension in extensionProviders)
SizedBox(
width: chipWidth,
child: _ServiceChip(
child: SettingsChoiceChip(
layout: SettingsChipLayout.column,
icon: Icons.extension,
label: extension.displayName,
isSelected: effectiveService == extension.id,
@@ -1000,64 +1074,6 @@ class _ServiceSelector extends ConsumerWidget {
}
}
class _ServiceChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({
required this.icon,
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 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: 14, horizontal: 12),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
class _MetadataSourceSelector extends ConsumerWidget {
const _MetadataSourceSelector();
@@ -1119,9 +1135,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
),
),
const SizedBox(height: 12),
_SettingsChoiceGrid(
SettingsChoiceGrid(
children: [
_SettingsChoiceChip(
SettingsChoiceChip(
icon: Icons.auto_awesome,
label: defaultProviderLabel,
isSelected: searchProvider.isEmpty,
@@ -1131,7 +1147,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
for (final ext in extState.extensions.where(
(e) => e.enabled && e.hasCustomSearch,
))
_SettingsChoiceChip(
SettingsChoiceChip(
icon: Icons.extension,
label: ext.displayName,
isSelected: searchProvider == ext.id,
@@ -1147,96 +1163,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
}
}
class _SettingsChoiceGrid extends StatelessWidget {
final List<Widget> children;
const _SettingsChoiceGrid({required this.children});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final columns = (constraints.maxWidth / 320).floor().clamp(2, 4);
final chipWidth =
(constraints.maxWidth - spacing * (columns - 1)) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final child in children)
SizedBox(width: chipWidth, child: child),
],
);
},
);
}
}
class _SettingsChoiceChip extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _SettingsChoiceChip({
required this.icon,
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 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, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 18,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
);
}
}
class _DefaultSearchTabSelector extends ConsumerWidget {
const _DefaultSearchTabSelector();
@@ -1274,7 +1200,7 @@ class _DefaultSearchTabSelector extends ConsumerWidget {
),
),
const SizedBox(height: 12),
_SettingsChoiceGrid(
SettingsChoiceGrid(
children: [
for (final tab in const [
'all',
@@ -1283,7 +1209,7 @@ class _DefaultSearchTabSelector extends ConsumerWidget {
'album',
'playlist',
])
_SettingsChoiceChip(
SettingsChoiceChip(
icon: switch (tab) {
'track' => Icons.music_note,
'artist' => Icons.person,
+21 -103
View File
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -10,7 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class FilesSettingsPage extends ConsumerStatefulWidget {
const FilesSettingsPage({super.key});
@@ -122,7 +123,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
child: Scaffold(
body: CustomScrollView(
slivers: [
SettingsSliverAppBar(title: context.l10n.settingsFiles),
AppSliverHeader.page(title: context.l10n.settingsFiles),
SliverToBoxAdapter(
child: SettingsSectionHeader(
@@ -151,54 +152,18 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
settings.storageMode == 'saf' &&
settings.downloadTreeUri.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadFolderAccessLostTitle,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onErrorContainer,
),
),
const SizedBox(height: 2),
Text(
context.l10n.downloadFolderAccessLostSubtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onErrorContainer
.withValues(alpha: 0.8),
),
),
],
),
),
TextButton(
onPressed: () async {
await _pickSafTreeAndSave();
await _checkSafAccess();
},
child: Text(context.l10n.downloadFolderReselect),
),
],
),
child: SettingsInfoCard(
icon: Icons.warning_amber_outlined,
tone: SettingsInfoTone.error,
title: context.l10n.downloadFolderAccessLostTitle,
message: context.l10n.downloadFolderAccessLostSubtitle,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 4),
action: FilledButton.tonal(
onPressed: () async {
await _pickSafTreeAndSave();
await _checkSafAccess();
},
child: Text(context.l10n.downloadFolderReselect),
),
),
),
@@ -521,9 +486,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -585,9 +547,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -677,33 +636,11 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.setupIosEmptyFolderWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
SettingsInfoCard(
icon: Icons.info_outline,
tone: SettingsInfoTone.warning,
message: context.l10n.setupIosEmptyFolderWarning,
margin: const EdgeInsets.fromLTRB(24, 8, 24, 16),
),
const SizedBox(height: 8),
],
@@ -729,9 +666,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
isScrollControlled: true,
useSafeArea: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => _FilenameFormatEditorSheet(
initialText: current,
onSave: save,
@@ -752,9 +686,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
@@ -852,9 +783,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
@@ -1090,17 +1018,7 @@ class _FilenameFormatEditorSheetState
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
const AppSheetHandle(),
Text(
widget.title ?? context.l10n.filenameFormat,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -12,7 +12,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
const LibrarySettingsPage({super.key});
@@ -259,9 +259,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -368,9 +365,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -432,9 +426,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -477,7 +468,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return Scaffold(
body: CustomScrollView(
slivers: [
SettingsSliverAppBar(title: context.l10n.libraryTitle),
AppSliverHeader.page(title: context.l10n.libraryTitle),
// Scan snapshots land ~2.5x/s; keep the per-snapshot rebuild scoped
// to the widgets that actually display scan state instead of the
@@ -829,34 +820,11 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.libraryBuiltInPlayerInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
child: SettingsInfoCard(
icon: Icons.info_outline,
tone: SettingsInfoTone.warning,
message: context.l10n.libraryBuiltInPlayerInfo,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
),
),
+240 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
/// Background fill for grouped cards, matching the Settings group look. Blends a
@@ -31,7 +32,7 @@ class SettingsGroup extends StatelessWidget {
final decoration = BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(context.tokens.radiusCard),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
@@ -297,3 +298,241 @@ class SettingsSectionHeader extends StatelessWidget {
);
}
}
/// How a [SettingsChoiceChip] stacks its icon and label.
enum SettingsChipLayout {
/// Icon beside the label. Fits wide chips in a grid.
row,
/// Icon above the label. Fits equal-width chips in a row.
column,
}
/// Single-select chip used across the settings pages.
///
/// Five private copies of this existed (theme mode, view mode, update channel,
/// download service, generic choice), each re-deriving the same unselected
/// fill and re-declaring radius 12. They now share one implementation, so the
/// selected/unselected treatment is identical everywhere.
class SettingsChoiceChip extends StatelessWidget {
const SettingsChoiceChip({
super.key,
required this.label,
required this.isSelected,
required this.onTap,
this.icon,
this.layout = SettingsChipLayout.row,
this.expand = false,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
final IconData? icon;
final SettingsChipLayout layout;
/// Wraps the chip in [Expanded] so a row of chips divides the width evenly.
final bool expand;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
final borderRadius = BorderRadius.circular(tokens.radiusCover);
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: colorScheme.surfaceContainerHigh;
final foreground = isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant;
final labelText = Text(
label,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: foreground,
),
);
final Widget content;
if (icon == null) {
content = Center(child: labelText);
} else if (layout == SettingsChipLayout.column) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: foreground),
SizedBox(height: tokens.gapXs + 2),
labelText,
],
);
} else {
content = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: foreground),
SizedBox(width: tokens.gapSm),
Flexible(child: labelText),
],
);
}
final chip = Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: borderRadius,
child: InkWell(
onTap: onTap,
borderRadius: borderRadius,
child: ConstrainedBox(
// Keeps every chip a comfortable tap target regardless of layout.
constraints: BoxConstraints(minHeight: tokens.minTouchTarget),
child: Padding(
padding: EdgeInsets.symmetric(
vertical: tokens.gapMd,
horizontal: tokens.gapMd,
),
child: content,
),
),
),
);
return expand ? Expanded(child: chip) : chip;
}
}
/// Lays out [SettingsChoiceChip]s in an even grid that reflows by width.
class SettingsChoiceGrid extends StatelessWidget {
const SettingsChoiceGrid({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final spacing = context.tokens.gapSm;
return LayoutBuilder(
builder: (context, constraints) {
final columns = (constraints.maxWidth / 320).floor().clamp(2, 4);
final chipWidth =
(constraints.maxWidth - spacing * (columns - 1)) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final child in children)
SizedBox(width: chipWidth, child: child),
],
);
},
);
}
}
/// Tone of a [SettingsInfoCard].
enum SettingsInfoTone { neutral, warning, error }
/// Inline explanatory or warning callout inside a settings page.
///
/// The `Container` + icon + text combination was inlined at a dozen call sites
/// with three different radii and four different container colours.
class SettingsInfoCard extends StatelessWidget {
const SettingsInfoCard({
super.key,
required this.message,
this.icon,
this.title,
this.tone = SettingsInfoTone.neutral,
this.action,
this.margin,
});
final String message;
final IconData? icon;
final String? title;
final SettingsInfoTone tone;
/// Optional trailing action, e.g. a retry or "grant permission" button.
final Widget? action;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final (background, foreground) = switch (tone) {
SettingsInfoTone.neutral => (
colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
colorScheme.onSurfaceVariant,
),
SettingsInfoTone.warning => (
colorScheme.tertiaryContainer.withValues(alpha: 0.5),
colorScheme.onTertiaryContainer,
),
SettingsInfoTone.error => (
colorScheme.errorContainer.withValues(alpha: 0.6),
colorScheme.onErrorContainer,
),
};
return Container(
margin:
margin ??
EdgeInsets.symmetric(
horizontal: tokens.gapLg,
vertical: tokens.gapXs,
),
padding: EdgeInsets.all(tokens.gapLg),
decoration: BoxDecoration(
color: background,
borderRadius: tokens.borderRadiusCard,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null) ...[
Icon(icon, size: 20, color: foreground),
SizedBox(width: tokens.gapMd),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null) ...[
Text(
title!,
style: theme.textTheme.bodyMedium?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: tokens.gapXs),
],
Text(
message,
style: theme.textTheme.bodySmall?.copyWith(color: foreground),
),
if (action != null) ...[
SizedBox(height: tokens.gapSm),
Align(alignment: Alignment.centerLeft, child: action!),
],
],
),
),
],
),
);
}
}