diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart index 15181d0e..14f1fe54 100644 --- a/lib/screens/settings/app_settings_page.dart +++ b/lib/screens/settings/app_settings_page.dart @@ -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, - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index d89c1c26..1035d31d 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -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 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 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, diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 8b84a475..5866c7eb 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -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 { 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 { 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 { 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 { 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 { 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 regions; + final String selected; + final String Function(BuildContext context, String code) countryNameOf; + final ValueChanged 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 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, diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 190fda36..aacee087 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -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 { 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 { 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 { 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 { 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 { ); }, ), - 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 { 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 { 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 { 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( diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 3135d06e..e3b9edb6 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -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 { 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 { 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 { 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 { 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 { ), ), 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), ), ), diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 5048b0a6..e1123c03 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -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 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!), + ], + ], + ), + ), + ], + ), + ); + } +}