UI modernization: Global theme update, redesigned Options/Download settings, and smart filename editor

This commit is contained in:
zarzet
2026-01-11 06:41:34 +07:00
parent 4eba28db7a
commit d07a49f605
4 changed files with 1227 additions and 557 deletions
+42 -1
View File
@@ -9,6 +9,44 @@
- Fallback to text search (artist + track name) when ISRC not found in Deezer
- Progress dialog shows enrichment status during import
- Ensures downloaded files have proper cover art and metadata
- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks
- "Open in Deezer" button for Deezer-sourced tracks (opens app or web)
- Displays "Deezer ID" instead of "Spotify ID" when applicable
- **Smart Tag Injection**: Filename format editor intelligently handles separators
- Auto-detects if " - " is needed between tags
- Prevents double separators or missing spaces
- **Dynamic Source Info**: Search source selector now shows helpful context
- "No login required" for Deezer
- "Requires credentials" for Spotify
### Changed
- **UI Modernization**: Major UI consistency updates across the app
- **Unified App Bars**: Home, History, and Settings now share identical behavior
- Lowered expanded header for easier one-handed reachability
- Dynamic title text scaling (20px to 34px)
- **Appearance Settings**: Completely redesigned appearance page
- New "Theme Preview" card showing visualizing current theme
- Modern color palette picker replacing old color dots
- Clean, grouped layout
- "AMOLED Dark" switch is now hidden when using Light Mode
- **App Logo**: Refined logo style on Home and About screens
- Inverted colors: Filled primary color circle with on-color icon
- Removed padding for a cleaner, bolder look
- **Material 3 Switches**: Added checkmark icon to active switches
- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look
- **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields
- **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest`
- **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius)
- **Options Settings Redesign**: improved layout and usability
- **Search Source Priority**: Moved "Search Source" section to the very top for quick access
- **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent
- **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing
- **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret
- **Filename Format Editor 2.0**:
- **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet
- **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion
- **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing
### Fixed
@@ -18,7 +56,7 @@
- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks
- Duration now fetched from Deezer metadata during enrichment
- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen
- Changed condition from `discNumber > 1` to `discNumber > 0`
- Changed condition from `discNumber > 0` to `discNumber > 0`
- Now displays disc 1 instead of hiding it
- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data
- Now uses `trackToDownload` (enriched) instead of `item.track` (original)
@@ -38,6 +76,9 @@
- Added `TotalSamples` to `AudioQuality` struct for duration calculation
- Updated `go_backend/exports.go`:
- `ReadFileMetadata` now returns duration calculated from FLAC stream info
- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions
- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities
- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling
---
+450 -137
View File
@@ -28,16 +28,25 @@ class DownloadSettingsPage extends ConsumerWidget {
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
'Download',
style: TextStyle(
@@ -51,89 +60,117 @@ class DownloadSettingsPage extends ConsumerWidget {
),
),
// Service section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ServiceSelector(
currentService: settings.defaultService,
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
),
],
// Service section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Service'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ServiceSelector(
currentService: settings.defaultService,
onChanged: (service) => ref
.read(settingsProvider.notifier)
.setDefaultService(service),
),
],
),
),
),
// Quality section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
subtitle: 'Choose quality for each download',
value: settings.askQualityBeforeDownload,
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value),
),
if (!settings.askQualityBeforeDownload) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
// Quality section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Audio Quality'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.tune,
title: 'Ask Before Download',
subtitle: 'Choose quality for each download',
value: settings.askQualityBeforeDownload,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'),
if (!settings.askQualityBeforeDownload) ...[
_QualityOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
isSelected: settings.audioQuality == 'LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('LOSSLESS'),
),
_QualityOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
isSelected: settings.audioQuality == 'HI_RES',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES'),
),
_QualityOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: false,
),
],
],
),
),
// File settings section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'File Settings'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(
context,
ref,
settings.filenameFormat,
),
),
_QualityOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS
? 'App Documents Folder'
: 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
subtitle: _getFolderOrganizationLabel(
settings.folderOrganization,
),
onTap: () => _showFolderOrganizationPicker(
context,
ref,
settings.folderOrganization,
),
showDivider: false,
),
],
],
),
),
),
// File settings section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'File Settings')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.text_fields,
title: 'Filename Format',
subtitle: settings.filenameFormat,
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsItem(
icon: Icons.create_new_folder_outlined,
title: 'Folder Organization',
subtitle: _getFolderOrganizationLabel(settings.folderOrganization),
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -141,26 +178,176 @@ class DownloadSettingsPage extends ConsumerWidget {
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
final tags = [
'{artist}',
'{title}',
'{album}',
'{track}',
'{year}',
'{disc}',
];
void insertTag(String tag) {
final text = controller.text;
final selection = controller.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
String insertion = tag;
if (start > 0) {
final before = text.substring(0, start);
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
if (!before.trim().endsWith('-')) {
insertion = ' - $tag';
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
// If ends with '-' but no space, add space
insertion = ' $tag';
}
}
final newText = text.replaceRange(start, end, insertion);
controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: start + insertion.length),
);
}
showModalBottomSheet(
context: context, isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
context: context,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
const SizedBox(height: 16),
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 24),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
]),
]),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
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),
),
),
),
Text(
'Filename Format',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Customize how your files are named.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: '{artist} - {title}',
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
autofocus: true,
),
const SizedBox(height: 24),
Text(
'Tap to insert tag:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: tags.map((tag) {
return ActionChip(
label: Text(tag),
onPressed: () => insertTag(tag),
backgroundColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
side: BorderSide.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
labelStyle: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
);
}).toList(),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.setFilenameFormat(controller.text);
Navigator.pop(context);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Save Format'),
),
),
],
),
const SizedBox(height: 8),
],
),
),
),
),
),
);
}
@@ -172,7 +359,8 @@ class DownloadSettingsPage extends ConsumerWidget {
} else {
// Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
if (result != null)
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
@@ -181,7 +369,9 @@ class DownloadSettingsPage extends ConsumerWidget {
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -189,13 +379,20 @@ class DownloadSettingsPage extends ConsumerWidget {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
child: Text(
'Download Location',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
@@ -205,7 +402,9 @@ class DownloadSettingsPage extends ConsumerWidget {
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await getApplicationDocumentsDirectory();
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(dir.path);
if (ctx.mounted) Navigator.pop(ctx);
},
),
@@ -218,7 +417,9 @@ class DownloadSettingsPage extends ConsumerWidget {
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
ref
.read(settingsProvider.notifier)
.setDownloadDirectory(result);
}
},
),
@@ -232,12 +433,18 @@ class DownloadSettingsPage extends ConsumerWidget {
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
@@ -264,12 +471,18 @@ class DownloadSettingsPage extends ConsumerWidget {
}
}
void _showFolderOrganizationPicker(BuildContext context, WidgetRef ref, String current) {
void _showFolderOrganizationPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -277,39 +490,69 @@ class DownloadSettingsPage extends ConsumerWidget {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Folder Organization', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text('Organize downloaded files into folders', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
child: Text(
'Organize downloaded files into folders',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_FolderOption(
title: 'None',
subtitle: 'All files in download folder',
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); },
onTap: () {
ref
.read(settingsProvider.notifier)
.setFolderOrganization('none');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist',
subtitle: 'Separate folder for each artist',
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); },
onTap: () {
ref
.read(settingsProvider.notifier)
.setFolderOrganization('artist');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Album',
subtitle: 'Separate folder for each album',
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); },
onTap: () {
ref
.read(settingsProvider.notifier)
.setFolderOrganization('album');
Navigator.pop(context);
},
),
_FolderOption(
title: 'By Artist & Album',
subtitle: 'Nested folders for artist and album',
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); },
onTap: () {
ref
.read(settingsProvider.notifier)
.setFolderOrganization('artist_album');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
@@ -322,19 +565,39 @@ class DownloadSettingsPage extends ConsumerWidget {
class _ServiceSelector extends StatelessWidget {
final String currentService;
final ValueChanged<String> onChanged;
const _ServiceSelector({required this.currentService, required this.onChanged});
const _ServiceSelector({
required this.currentService,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
const SizedBox(width: 8),
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
const SizedBox(width: 8),
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
]),
child: Row(
children: [
_ServiceChip(
icon: Icons.music_note,
label: 'Tidal',
isSelected: currentService == 'tidal',
onTap: () => onChanged('tidal'),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: currentService == 'qobuz',
onTap: () => onChanged('qobuz'),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: currentService == 'amazon',
onTap: () => onChanged('amazon'),
),
],
),
);
}
}
@@ -344,17 +607,25 @@ class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.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)
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
@@ -364,13 +635,29 @@ class _ServiceChip extends StatelessWidget {
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)),
]),
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,
),
),
],
),
),
),
),
@@ -384,7 +671,13 @@ class _QualityOption extends StatelessWidget {
final bool isSelected;
final VoidCallback onTap;
final bool showDivider;
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true});
const _QualityOption({
required this.title,
required this.subtitle,
required this.isSelected,
required this.onTap,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
@@ -404,11 +697,16 @@ class _QualityOption extends StatelessWidget {
children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
isSelected
isSelected
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
],
@@ -434,7 +732,13 @@ class _FolderOption extends StatelessWidget {
final String example;
final bool isSelected;
final VoidCallback onTap;
const _FolderOption({required this.title, required this.subtitle, required this.example, required this.isSelected, required this.onTap});
const _FolderOption({
required this.title,
required this.subtitle,
required this.example,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
@@ -447,10 +751,19 @@ class _FolderOption extends StatelessWidget {
children: [
Text(subtitle),
const SizedBox(height: 4),
Text(example, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: colorScheme.primary)),
Text(
example,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: colorScheme.primary,
),
),
],
),
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
trailing: isSelected
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: onTap,
);
}
File diff suppressed because it is too large Load Diff
+105 -84
View File
@@ -7,11 +7,9 @@ class AppTheme {
static const Color defaultSeedColor = Color(kDefaultSeedColor);
/// Create light theme
static ThemeData light({
ColorScheme? dynamicScheme,
Color? seedColor,
}) {
final scheme = dynamicScheme ??
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
final scheme =
dynamicScheme ??
ColorScheme.fromSeed(
seedColor: seedColor ?? defaultSeedColor,
brightness: Brightness.light,
@@ -45,7 +43,8 @@ class AppTheme {
Color? seedColor,
bool isAmoled = false,
}) {
final scheme = dynamicScheme ??
final scheme =
dynamicScheme ??
ColorScheme.fromSeed(
seedColor: seedColor ?? defaultSeedColor,
brightness: Brightness.dark,
@@ -75,34 +74,41 @@ class AppTheme {
}
/// AppBar theme
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
elevation: 0,
scrolledUnderElevation: isAmoled ? 0 : 3,
backgroundColor: isAmoled ? Colors.black : scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
centerTitle: true,
titleTextStyle: TextStyle(
color: scheme.onSurface,
fontSize: 22,
fontWeight: FontWeight.w500,
),
);
static AppBarTheme _appBarTheme(
ColorScheme scheme, {
bool isAmoled = false,
}) => AppBarTheme(
elevation: 0,
scrolledUnderElevation: isAmoled ? 0 : 3,
backgroundColor: isAmoled ? Colors.black : scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
centerTitle: true,
titleTextStyle: TextStyle(
color: scheme.onSurface,
fontSize: 22,
fontWeight: FontWeight.w500,
),
);
/// Card theme
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint,
);
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 12 -> 16
color: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint,
);
/// Elevated button theme
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
@@ -111,7 +117,9 @@ class AppTheme {
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
@@ -120,7 +128,9 @@ class AppTheme {
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
);
@@ -129,7 +139,9 @@ class AppTheme {
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 20 -> 16
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
);
@@ -147,52 +159,63 @@ class AppTheme {
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest,
fillColor: scheme.surfaceContainerHighest.withValues(
alpha: 0.3,
), // Added transparency
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide(color: scheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16), // 12 -> 16
borderSide: BorderSide(color: scheme.error, width: 1),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
), // consistent padding
);
/// List tile theme
static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), // 12 -> 16
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
/// Dialog theme
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
backgroundColor: scheme.surfaceContainerHigh,
surfaceTintColor: scheme.surfaceTint,
);
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
backgroundColor: scheme.surfaceContainerHigh,
surfaceTintColor: scheme.surfaceTint,
);
/// Navigation bar theme
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
NavigationBarThemeData(
elevation: 0,
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
indicatorColor: scheme.secondaryContainer,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
);
static NavigationBarThemeData _navigationBarTheme(
ColorScheme scheme, {
bool isAmoled = false,
}) => NavigationBarThemeData(
elevation: 0,
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
indicatorColor: scheme.secondaryContainer,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
);
/// SnackBar theme
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData(
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: scheme.inverseSurface,
@@ -200,46 +223,44 @@ class AppTheme {
);
/// Progress indicator theme
static ProgressIndicatorThemeData _progressIndicatorTheme(ColorScheme scheme) =>
ProgressIndicatorThemeData(
color: scheme.primary,
linearTrackColor: scheme.surfaceContainerHighest,
circularTrackColor: scheme.surfaceContainerHighest,
);
static ProgressIndicatorThemeData _progressIndicatorTheme(
ColorScheme scheme,
) => ProgressIndicatorThemeData(
color: scheme.primary,
linearTrackColor: scheme.surfaceContainerHighest,
circularTrackColor: scheme.surfaceContainerHighest,
);
/// Switch theme
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return scheme.onPrimary;
}
return scheme.outline;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.surfaceContainerHighest;
}),
thumbIcon: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return Icon(Icons.check, color: scheme.primary);
}
return null;
}),
);
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return scheme.onPrimary;
}
return scheme.outline;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.surfaceContainerHighest;
}),
thumbIcon: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return Icon(Icons.check, color: scheme.primary);
}
return null;
}),
);
/// Chip theme
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: scheme.surfaceContainerLow,
selectedColor: scheme.secondaryContainer,
);
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: scheme.surfaceContainerLow,
selectedColor: scheme.secondaryContainer,
);
/// Divider theme
static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
);
static DividerThemeData _dividerTheme(ColorScheme scheme) =>
DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
}