mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-24 16:54:03 +02:00
UI Modernization: Unified app bars, updated logos, improved settings & Deezer support
This commit is contained in:
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [2.2.7] - 2026-01-11
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
### 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
|
||||
- **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
|
||||
|
||||
## [2.2.6] - 2026-01-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Release Mode Logging**: Flutter app logs now properly captured in release builds
|
||||
- Previously only Go backend logs appeared when "Detailed Logging" was enabled
|
||||
- Now both Flutter and Go logs are captured in release mode
|
||||
- Bypasses Logger package which filters logs in release mode
|
||||
|
||||
### Added
|
||||
|
||||
- **Detailed Deezer Search Logging**: Better debugging for search issues
|
||||
- Logs API URLs, response counts, and errors
|
||||
- Helps diagnose geo-restriction and API issues
|
||||
- Detects Deezer API error responses
|
||||
|
||||
### Changed
|
||||
|
||||
- **Home Screen Logo**: Replaced music note icon with app logo
|
||||
- Uses `assets/images/logo.png`
|
||||
- Rounded corners (24px radius)
|
||||
- Fallback to music note icon if logo fails to load
|
||||
- **About Page Logo**: Removed shadow/border from logo
|
||||
- Cleaner appearance without background container
|
||||
- **About Page Icon Alignment**: Icons now aligned with contributor avatars
|
||||
- DoubleDouble and DAB Music icons use 40x40 area
|
||||
- Text now properly aligned with contributor items
|
||||
|
||||
## [2.2.5] - 2026-01-10
|
||||
|
||||
### Added
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
+39
-29
@@ -289,6 +289,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
|
||||
return Scaffold(
|
||||
@@ -297,24 +298,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
slivers: [
|
||||
// App Bar - always present
|
||||
SliverAppBar(
|
||||
expandedHeight: 130,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Home',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
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);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Home',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -328,24 +337,25 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
: Column(
|
||||
children: [
|
||||
SizedBox(height: screenHeight * 0.06),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 96,
|
||||
height: 96,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 48,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
'assets/images/logo-transparant.png',
|
||||
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
// Fallback to original logo if transparent one is missing
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 96,
|
||||
height: 96,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+21
-12
@@ -99,29 +99,38 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar - Simplified for performance
|
||||
SliverAppBar(
|
||||
expandedHeight: 130,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'History',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
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);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'History',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -250,24 +250,25 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
// App logo
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
// App logo
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 88,
|
||||
height: 88,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 48,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
'assets/images/logo-transparant.png',
|
||||
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 88,
|
||||
height: 88,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -31,6 +31,42 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
||||
),
|
||||
|
||||
// Preview Section
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: _ThemePreviewCard(),
|
||||
),
|
||||
),
|
||||
|
||||
// Color section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.wallpaper,
|
||||
title: 'Dynamic Color',
|
||||
subtitle: 'Use colors from your wallpaper',
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!themeSettings.useDynamicColor)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: _ColorPalettePicker(
|
||||
currentColor: themeSettings.seedColorValue,
|
||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Theme section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
||||
SliverToBoxAdapter(
|
||||
@@ -43,7 +79,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.brightness_2,
|
||||
title: 'AMOLED Dark',
|
||||
subtitle: 'Pure black background for OLED screens',
|
||||
subtitle: 'Pure black background',
|
||||
value: themeSettings.useAmoled,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||
showDivider: false,
|
||||
@@ -52,28 +88,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Color section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.auto_awesome,
|
||||
title: 'Dynamic Color',
|
||||
subtitle: 'Use colors from your wallpaper',
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||
showDivider: !themeSettings.useDynamicColor,
|
||||
),
|
||||
if (!themeSettings.useDynamicColor)
|
||||
_ColorPicker(
|
||||
currentColor: themeSettings.seedColorValue,
|
||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Layout section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
||||
SliverToBoxAdapter(
|
||||
@@ -88,7 +102,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Fill remaining for scroll
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -96,6 +110,230 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A simplified preview of how the app looks with current settings
|
||||
class _ThemePreviewCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest, // Background similar to reference
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Decorative background blobs
|
||||
Positioned(
|
||||
top: -50,
|
||||
right: -50,
|
||||
child: Container(
|
||||
width: 200, height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
child: Container(
|
||||
width: 150, height: 150,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Foreground "fake UI"
|
||||
Center(
|
||||
child: Container(
|
||||
width: 260,
|
||||
height: 140,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Fake Album Art
|
||||
Container(
|
||||
width: 108,
|
||||
height: 108,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onPrimary, size: 48),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Fake Text Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity, height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 80, height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.skip_previous, size: 24, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.play_circle_fill, size: 32, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.skip_next, size: 24, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Label badge
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isDark ? 'Dark Mode' : 'Light Mode',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class _ColorPalettePicker extends StatelessWidget {
|
||||
final int currentColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
const _ColorPalettePicker({required this.currentColor, required this.onColorSelected});
|
||||
|
||||
static const _colors = [
|
||||
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
|
||||
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _colors.map((color) {
|
||||
final isSelected = color.toARGB32() == currentColor;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => onColorSelected(color),
|
||||
child: _ColorPaletteItem(color: color, isSelected: isSelected),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorPaletteItem extends StatelessWidget {
|
||||
final Color color;
|
||||
final bool isSelected;
|
||||
|
||||
const _ColorPaletteItem({required this.color, required this.isSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: color, brightness: Theme.of(context).brightness);
|
||||
final size = 64.0;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Container(color: scheme.primaryContainer)),
|
||||
Expanded(child: Container(color: scheme.tertiaryContainer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Container(color: scheme.secondaryContainer)),
|
||||
Expanded(child: Container(color: scheme.surfaceContainer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.check, size: 16, color: scheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimized app bar title with animation
|
||||
class _AppBarTitle extends StatelessWidget {
|
||||
final String title;
|
||||
@@ -200,45 +438,6 @@ class _ThemeModeChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorPicker extends StatelessWidget {
|
||||
final int currentColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
const _ColorPicker({required this.currentColor, required this.onColorSelected});
|
||||
|
||||
static const _colors = [
|
||||
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
|
||||
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
||||
final isSelected = color.toARGB32() == currentColor;
|
||||
return GestureDetector(
|
||||
onTap: () => onColorSelected(color),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color, shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
|
||||
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
|
||||
),
|
||||
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryViewSelector extends StatelessWidget {
|
||||
final String currentMode;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@@ -14,29 +14,38 @@ class SettingsTab extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 130,
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.3,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
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);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Settings',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -353,19 +353,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
// Metadata grid
|
||||
_buildMetadataGrid(context, colorScheme),
|
||||
|
||||
// Spotify link button
|
||||
// Streaming service link button
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openSpotifyUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('Open in Spotify'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => _openServiceUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -374,16 +379,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
||||
Future<void> _openServiceUrl(BuildContext context) async {
|
||||
if (item.spotifyId == null) return;
|
||||
|
||||
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
||||
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
final rawId = item.spotifyId!.replaceAll('deezer:', '');
|
||||
|
||||
final webUrl = isDeezer
|
||||
? 'https://www.deezer.com/track/$rawId'
|
||||
: 'https://open.spotify.com/track/$rawId';
|
||||
|
||||
final appUri = isDeezer
|
||||
? Uri.parse('deezer://www.deezer.com/track/$rawId')
|
||||
: Uri.parse('spotify:track:$rawId');
|
||||
|
||||
try {
|
||||
// Try to open in Spotify app first using URI scheme
|
||||
// Try to open in App first using URI scheme
|
||||
final launched = await launchUrl(
|
||||
spotifyUri,
|
||||
appUri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
@@ -406,7 +419,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, webUrl);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
||||
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -439,11 +452,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_MetadataItem('Release date', releaseDate!),
|
||||
if (isrc != null && isrc!.isNotEmpty)
|
||||
_MetadataItem('ISRC', isrc!),
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||
_MetadataItem('Spotify ID', item.spotifyId!),
|
||||
];
|
||||
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||
final isDeezer = item.spotifyId!.contains('deezer');
|
||||
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
|
||||
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
|
||||
}
|
||||
|
||||
items.addAll([
|
||||
_MetadataItem('Service', item.service.toUpperCase()),
|
||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||
];
|
||||
]);
|
||||
|
||||
return Column(
|
||||
children: items.map((metadata) {
|
||||
|
||||
@@ -221,6 +221,12 @@ class AppTheme {
|
||||
}
|
||||
return scheme.surfaceContainerHighest;
|
||||
}),
|
||||
thumbIcon: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return Icon(Icons.check, color: scheme.primary);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
/// Chip theme
|
||||
|
||||
Reference in New Issue
Block a user