mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
refactor(settings): unify settings components
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user