diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f1eaed..bf2f946c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [1.5.6] - 2026-01-02 + +### Fixed +- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`) + - Users on hotfix versions now properly receive update notifications + - Handles `-hotfix`, `-beta`, `-rc` suffixes correctly + +### Added +- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs + +### Changed +- **Settings UI Redesign**: New Android-style grouped settings with connected cards + - Items in same group are connected with rounded card container + - Section headers outside cards for clear visual hierarchy + - Better contrast with white overlay for dark mode dynamic colors +- **Larger Tab Titles**: Increased app bar title size (28px) and height (130px) for better visibility +- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs + +### Improved +- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package + ## [1.5.5] - 2026-01-02 ### Added diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 00000000..e80af511 Binary files /dev/null and b/flutter_01.png differ diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 10b74e8d..402bbff5 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '1.5.5'; - static const String buildNumber = '22'; + static const String version = '1.5.6'; + static const String buildNumber = '23'; static const String fullVersion = '$version+$buildNumber'; static const String appName = 'SpotiFLAC'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 443f5dec..1fd5cf9f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -13,6 +13,10 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('DownloadQueue'); +final _historyLog = AppLogger('DownloadHistory'); // Download History Item model class DownloadHistoryItem { @@ -132,12 +136,12 @@ class DownloadHistoryNotifier extends Notifier { final List jsonList = jsonDecode(jsonStr); final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map)).toList(); state = state.copyWith(items: items); - print('[DownloadHistory] Loaded ${items.length} items from storage'); + _historyLog.i('Loaded ${items.length} items from storage'); } else { - print('[DownloadHistory] No history found in storage'); + _historyLog.d('No history found in storage'); } } catch (e) { - print('[DownloadHistory] Failed to load history: $e'); + _historyLog.e('Failed to load history: $e'); } } @@ -146,9 +150,9 @@ class DownloadHistoryNotifier extends Notifier { final prefs = await SharedPreferences.getInstance(); final jsonList = state.items.map((e) => e.toJson()).toList(); await prefs.setString(_storageKey, jsonEncode(jsonList)); - print('[DownloadHistory] Saved ${state.items.length} items to storage'); + _historyLog.d('Saved ${state.items.length} items to storage'); } catch (e) { - print('[DownloadHistory] Failed to save history: $e'); + _historyLog.e('Failed to save history: $e'); } } @@ -277,7 +281,7 @@ class DownloadQueueNotifier extends Notifier { // Log progress final mbReceived = bytesReceived / (1024 * 1024); final mbTotal = bytesTotal / (1024 * 1024); - print('[DownloadQueue] Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)'); + _log.d('Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)'); } } catch (e) { // Ignore polling errors @@ -307,7 +311,7 @@ class DownloadQueueNotifier extends Notifier { // Log progress for each item final mbReceived = bytesReceived / (1024 * 1024); final mbTotal = bytesTotal / (1024 * 1024); - print('[DownloadQueue] Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)'); + _log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)'); } } @@ -424,7 +428,7 @@ class DownloadQueueNotifier extends Notifier { final dir = Directory(fullPath); if (!await dir.exists()) { await dir.create(recursive: true); - print('[DownloadQueue] Created folder: $fullPath'); + _log.d('Created folder: $fullPath'); } return fullPath; } @@ -531,7 +535,7 @@ class DownloadQueueNotifier extends Notifier { if (state.isProcessing && !state.isPaused) { state = state.copyWith(isPaused: true); _notificationService.cancelDownloadNotification(); - print('[DownloadQueue] Queue paused'); + _log.i('Queue paused'); } } @@ -539,7 +543,7 @@ class DownloadQueueNotifier extends Notifier { void resumeQueue() { if (state.isPaused) { state = state.copyWith(isPaused: false); - print('[DownloadQueue] Queue resumed'); + _log.i('Queue resumed'); // If there are still queued items, continue processing if (state.queuedCount > 0 && !state.isProcessing) { Future.microtask(() => _processQueue()); @@ -594,14 +598,14 @@ class DownloadQueueNotifier extends Notifier { final sink = file.openWrite(); await response.pipe(sink); await sink.close(); - print('[DownloadQueue] Cover downloaded to: $coverPath'); + _log.d('Cover downloaded to: $coverPath'); } else { - print('[DownloadQueue] Failed to download cover: HTTP ${response.statusCode}'); + _log.w('Failed to download cover: HTTP ${response.statusCode}'); coverPath = null; } httpClient.close(); } catch (e) { - print('[DownloadQueue] Failed to download cover: $e'); + _log.e('Failed to download cover: $e'); coverPath = null; } } @@ -621,10 +625,10 @@ class DownloadQueueNotifier extends Notifier { // Replace original with temp await File(flacPath).delete(); await File(tempOutput).rename(flacPath); - print('[DownloadQueue] Cover embedded via FFmpeg'); + _log.d('Cover embedded via FFmpeg'); } else { // Try alternative method using metaflac-style embedding - print('[DownloadQueue] FFmpeg cover embed failed, trying alternative...'); + _log.w('FFmpeg cover embed failed, trying alternative...'); // Clean up temp file if exists final tempFile = File(tempOutput); if (await tempFile.exists()) { @@ -638,7 +642,7 @@ class DownloadQueueNotifier extends Notifier { } catch (_) {} } } catch (e) { - print('[DownloadQueue] Failed to embed metadata: $e'); + _log.e('Failed to embed metadata: $e'); } } @@ -646,20 +650,20 @@ class DownloadQueueNotifier extends Notifier { if (state.isProcessing) return; // Prevent multiple concurrent processing state = state.copyWith(isProcessing: true); - print('[DownloadQueue] Starting queue processing...'); + _log.i('Starting queue processing...'); // Track total items at start for notification _totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length; // Ensure output directory is initialized before processing if (state.outputDir.isEmpty) { - print('[DownloadQueue] Output dir empty, initializing...'); + _log.d('Output dir empty, initializing...'); await _initOutputDir(); } // If still empty, use fallback if (state.outputDir.isEmpty) { - print('[DownloadQueue] Using fallback directory...'); + _log.d('Using fallback directory...'); final dir = await getApplicationDocumentsDirectory(); final musicDir = Directory('${dir.path}/SpotiFLAC'); if (!await musicDir.exists()) { @@ -668,8 +672,8 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: musicDir.path); } - print('[DownloadQueue] Output directory: ${state.outputDir}'); - print('[DownloadQueue] Concurrent downloads: ${state.concurrentDownloads}'); + _log.d('Output directory: ${state.outputDir}'); + _log.d('Concurrent downloads: ${state.concurrentDownloads}'); // Use parallel processing if concurrentDownloads > 1 if (state.concurrentDownloads > 1) { @@ -682,11 +686,11 @@ class DownloadQueueNotifier extends Notifier { // Final cleanup after queue finishes if (_downloadCount > 0) { - print('[DownloadQueue] Final connection cleanup...'); + _log.d('Final connection cleanup...'); try { await PlatformBridge.cleanupConnections(); } catch (e) { - print('[DownloadQueue] Final cleanup failed: $e'); + _log.e('Final cleanup failed: $e'); } _downloadCount = 0; } @@ -701,7 +705,7 @@ class DownloadQueueNotifier extends Notifier { ); } - print('[DownloadQueue] Queue processing finished'); + _log.i('Queue processing finished'); state = state.copyWith(isProcessing: false, currentDownload: null); } @@ -710,7 +714,7 @@ class DownloadQueueNotifier extends Notifier { while (true) { // Check if paused if (state.isPaused) { - print('[DownloadQueue] Queue is paused, waiting...'); + _log.d('Queue is paused, waiting...'); await Future.delayed(const Duration(milliseconds: 500)); continue; } @@ -726,7 +730,7 @@ class DownloadQueueNotifier extends Notifier { ); if (nextItem.id.isEmpty) { - print('[DownloadQueue] No more items to process'); + _log.d('No more items to process'); break; } @@ -745,7 +749,7 @@ class DownloadQueueNotifier extends Notifier { while (true) { // Check if paused - don't start new downloads but let active ones finish if (state.isPaused) { - print('[DownloadQueue] Queue is paused, waiting for active downloads...'); + _log.d('Queue is paused, waiting for active downloads...'); if (activeDownloads.isNotEmpty) { await Future.any(activeDownloads.values); } else { @@ -758,7 +762,7 @@ class DownloadQueueNotifier extends Notifier { final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList(); if (queuedItems.isEmpty && activeDownloads.isEmpty) { - print('[DownloadQueue] No more items to process'); + _log.d('No more items to process'); break; } @@ -777,7 +781,7 @@ class DownloadQueueNotifier extends Notifier { }); activeDownloads[item.id] = future; - print('[DownloadQueue] Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)'); + _log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)'); } // Wait for at least one download to complete before checking for more @@ -794,8 +798,8 @@ class DownloadQueueNotifier extends Notifier { /// Download a single item (used by both sequential and parallel processing) Future _downloadSingleItem(DownloadItem item) async { - print('[DownloadQueue] Processing: ${item.track.name} by ${item.track.artistName}'); - print('[DownloadQueue] Cover URL: ${item.track.coverUrl}'); + _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); + _log.d('Cover URL: ${item.track.coverUrl}'); // Only set currentDownload for sequential mode (for progress polling) if (state.concurrentDownloads == 1) { @@ -813,9 +817,9 @@ class DownloadQueueNotifier extends Notifier { Map result; if (state.autoFallback) { - print('[DownloadQueue] Using auto-fallback mode'); - print('[DownloadQueue] Quality: ${state.audioQuality}'); - print('[DownloadQueue] Output dir: $outputDir'); + _log.d('Using auto-fallback mode'); + _log.d('Quality: ${state.audioQuality}'); + _log.d('Output dir: $outputDir'); result = await PlatformBridge.downloadWithFallback( isrc: item.track.isrc ?? '', spotifyId: item.track.id, @@ -860,31 +864,31 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); } - print('[DownloadQueue] Result: $result'); + _log.d('Result: $result'); if (result['success'] == true) { var filePath = result['file_path'] as String?; - print('[DownloadQueue] Download success, file: $filePath'); + _log.i('Download success, file: $filePath'); // Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC if (filePath != null && filePath.endsWith('.m4a')) { - print('[DownloadQueue] Converting M4A to FLAC...'); + _log.d('Converting M4A to FLAC...'); updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9); final flacPath = await FFmpegService.convertM4aToFlac(filePath); if (flacPath != null) { filePath = flacPath; - print('[DownloadQueue] Converted to: $flacPath'); + _log.d('Converted to: $flacPath'); // After conversion, embed metadata and cover to the new FLAC file - print('[DownloadQueue] Embedding metadata and cover to converted FLAC...'); + _log.d('Embedding metadata and cover to converted FLAC...'); try { await _embedMetadataAndCover( flacPath, item.track, ); - print('[DownloadQueue] Metadata and cover embedded successfully'); + _log.d('Metadata and cover embedded successfully'); } catch (e) { - print('[DownloadQueue] Warning: Failed to embed metadata/cover: $e'); + _log.w('Warning: Failed to embed metadata/cover: $e'); } } } @@ -932,7 +936,7 @@ class DownloadQueueNotifier extends Notifier { } } else { final errorMsg = result['error'] as String? ?? 'Download failed'; - print('[DownloadQueue] Download failed: $errorMsg'); + _log.e('Download failed: $errorMsg'); updateItemStatus( item.id, DownloadStatus.failed, @@ -943,19 +947,18 @@ class DownloadQueueNotifier extends Notifier { // Increment download counter and cleanup connections periodically _downloadCount++; if (_downloadCount % _cleanupInterval == 0) { - print('[DownloadQueue] Cleaning up idle connections (after $_downloadCount downloads)...'); + _log.d('Cleaning up idle connections (after $_downloadCount downloads)...'); try { await PlatformBridge.cleanupConnections(); } catch (e) { - print('[DownloadQueue] Connection cleanup failed: $e'); + _log.e('Connection cleanup failed: $e'); } } } catch (e, stackTrace) { if (state.concurrentDownloads == 1) { _stopProgressPolling(); } - print('[DownloadQueue] Exception: $e'); - print('[DownloadQueue] StackTrace: $stackTrace'); + _log.e('Exception: $e', e, stackTrace); updateItemStatus( item.id, DownloadStatus.failed, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 62f2258f..b5423b6f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -74,13 +74,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final hasResults = _hasResults; return Scaffold( - body: SafeArea( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: hasResults - ? _buildResultsView(trackState, colorScheme) - : _buildCenteredSearch(colorScheme), - ), + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: hasResults + ? _buildResultsView(trackState, colorScheme) + : _buildCenteredSearch(colorScheme), ), ); } @@ -89,57 +87,86 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget _buildCenteredSearch(ColorScheme colorScheme) { final historyItems = ref.watch(downloadHistoryProvider).items; - return Center( + return CustomScrollView( key: const ValueKey('centered'), - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // App icon/logo - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), - ), - const SizedBox(height: 24), - Text( - 'Search Music', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( + slivers: [ + // Collapsing App Bar - same style as other tabs + SliverAppBar( + expandedHeight: 130, + 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( + 'Search', + style: TextStyle( + fontSize: 28, fontWeight: FontWeight.bold, + color: colorScheme.onSurface, ), ), - const SizedBox(height: 8), - Text( - 'Paste a Spotify link or search by name', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 32), - // Search bar - _buildSearchBar(colorScheme), - const SizedBox(height: 12), - // Helper text - if (!ref.watch(settingsProvider).hasSearchedBefore) - Text( - 'Supports: Track, Album, Playlist, Artist URLs', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - // Recent downloads - compact horizontal scroll - if (historyItems.isNotEmpty) ...[ - const SizedBox(height: 32), - _buildRecentDownloads(historyItems, colorScheme), - ], - ], + ), ), - ), + + // Content + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 24), + // App icon/logo + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon(Icons.music_note, size: 48, color: colorScheme.primary), + ), + const SizedBox(height: 24), + Text( + 'Search Music', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Paste a Spotify link or search by name', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + // Search bar + _buildSearchBar(colorScheme), + const SizedBox(height: 12), + // Helper text + if (!ref.watch(settingsProvider).hasSearchedBefore) + Text( + 'Supports: Track, Album, Playlist, Artist URLs', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + // Recent downloads - compact horizontal scroll + if (historyItems.isNotEmpty) ...[ + const SizedBox(height: 32), + _buildRecentDownloads(historyItems, colorScheme), + ], + ], + ), + ), + ), + ], ); } @@ -231,7 +258,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient slivers: [ // Collapsing App Bar SliverAppBar( - expandedHeight: 100, + expandedHeight: 130, collapsedHeight: kToolbarHeight, floating: false, pinned: true, @@ -239,12 +266,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, flexibleSpace: FlexibleSpaceBar( - expandedTitleScale: 1.4, + expandedTitleScale: 1.3, titlePadding: const EdgeInsets.only(left: 24, bottom: 16), title: Text( 'Search', style: TextStyle( - fontSize: 20, + fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 2b9511bc..d2b9f3d2 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -11,6 +11,9 @@ import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('MainShell'); class MainShell extends ConsumerStatefulWidget { const MainShell({super.key}); @@ -40,13 +43,13 @@ class _MainShellState extends ConsumerState { // Check for pending URL that was received before listener was ready final pendingUrl = ShareIntentService().consumePendingUrl(); if (pendingUrl != null) { - print('[MainShell] Processing pending shared URL: $pendingUrl'); + _log.d('Processing pending shared URL: $pendingUrl'); _handleSharedUrl(pendingUrl); } // Listen for future shared URLs _shareSubscription = ShareIntentService().sharedUrlStream.listen((url) { - print('[MainShell] Received shared URL from stream: $url'); + _log.d('Received shared URL from stream: $url'); _handleSharedUrl(url); }); } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 69b434f4..67e0b094 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -78,7 +78,7 @@ class _QueueTabState extends ConsumerState { slivers: [ // Collapsing App Bar - Simplified for performance SliverAppBar( - expandedHeight: 100, + expandedHeight: 130, collapsedHeight: kToolbarHeight, floating: false, pinned: true, @@ -86,12 +86,12 @@ class _QueueTabState extends ConsumerState { surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, flexibleSpace: FlexibleSpaceBar( - expandedTitleScale: 1.4, + expandedTitleScale: 1.3, titlePadding: const EdgeInsets.only(left: 24, bottom: 16), title: Text( 'History', style: TextStyle( - fontSize: 20, + fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 2fbdfb74..853644f0 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -69,7 +69,7 @@ class AboutPage extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.asset('assets/images/logo.png', fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)), + errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 32, color: colorScheme.onPrimaryContainer)), ), ), const SizedBox(width: 16), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 6922f03b..40bbd4c5 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; class AppearanceSettingsPage extends ConsumerWidget { const AppearanceSettingsPage({super.key}); @@ -56,46 +57,50 @@ class AppearanceSettingsPage extends ConsumerWidget { ), // Theme section - SliverToBoxAdapter(child: _SectionHeader(title: 'Theme')), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _ThemeModeSelector( - currentMode: themeSettings.themeMode, - onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), - ), + child: SettingsGroup( + children: [ + _ThemeModeSelector( + currentMode: themeSettings.themeMode, + onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode), + ), + ], ), ), // Color section - SliverToBoxAdapter(child: _SectionHeader(title: 'Color')), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')), SliverToBoxAdapter( - child: SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - title: const Text('Dynamic Color'), - subtitle: const Text('Use colors from your wallpaper'), - value: themeSettings.useDynamicColor, - onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value), + 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), + ), + ], ), ), - if (!themeSettings.useDynamicColor) - SliverToBoxAdapter( - child: _ColorPicker( - currentColor: themeSettings.seedColorValue, - onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color), - ), - ), - // Layout section - SliverToBoxAdapter(child: _SectionHeader(title: 'Layout')), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _HistoryViewSelector( - currentMode: settings.historyViewMode, - onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode), - ), + child: SettingsGroup( + children: [ + _HistoryViewSelector( + currentMode: settings.historyViewMode, + onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode), + ), + ], ), ), @@ -107,17 +112,6 @@ class AppearanceSettingsPage extends ConsumerWidget { } } -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)), - ); -} - class _ThemeModeSelector extends StatelessWidget { final ThemeMode currentMode; final ValueChanged onChanged; @@ -125,21 +119,15 @@ class _ThemeModeSelector extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Card( - elevation: 0, - color: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(8), - child: Row(children: [ - _ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)), - const SizedBox(width: 8), - _ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)), - const SizedBox(width: 8), - _ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)), - ]), - ), + return Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [ + _ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)), + const SizedBox(width: 8), + _ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)), + const SizedBox(width: 8), + _ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)), + ]), ); } } @@ -154,9 +142,16 @@ class _ThemeModeChip extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + // Unselected chips need to be darker than the card background + final unselectedColor = isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) + : colorScheme.surfaceContainerHigh; + return Expanded( child: Material( - color: isSelected ? colorScheme.primaryContainer : Colors.transparent, + color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, @@ -191,9 +186,9 @@ class _ColorPicker extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Accent Color', style: Theme.of(context).textTheme.titleSmall), + 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; @@ -224,26 +219,21 @@ class _HistoryViewSelector extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Card( - elevation: 0, - color: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 8, bottom: 8), - child: Text('History View', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - Row(children: [ - _ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')), - const SizedBox(width: 8), - _ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')), - ]), - ], - ), + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), + Row(children: [ + _ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')), + const SizedBox(width: 8), + _ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')), + ]), + ], ), ); } @@ -259,9 +249,15 @@ class _ViewModeChip extends StatelessWidget { @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 : Colors.transparent, + color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 8f24420b..c642ca6f 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; class DownloadSettingsPage extends ConsumerWidget { const DownloadSettingsPage({super.key}); @@ -55,59 +56,73 @@ class DownloadSettingsPage extends ConsumerWidget { ), // Service section - SliverToBoxAdapter(child: _SectionHeader(title: 'Service')), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _ServiceSelector( - currentService: settings.defaultService, - onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service), - ), + child: SettingsGroup( + children: [ + _ServiceSelector( + currentService: settings.defaultService, + onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service), + ), + ], ), ), // Quality section - SliverToBoxAdapter(child: _SectionHeader(title: 'Audio Quality')), - SliverList(delegate: SliverChildListDelegate([ - _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', value: 'LOSSLESS', - isSelected: settings.audioQuality == 'LOSSLESS', - onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS')), - _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', value: 'HI_RES', - 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', value: 'HI_RES_LOSSLESS', - isSelected: settings.audioQuality == 'HI_RES_LOSSLESS', - onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS')), - ])), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _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 - SliverToBoxAdapter(child: _SectionHeader(title: 'File Settings')), - SliverList(delegate: SliverChildListDelegate([ - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: Icon(Icons.text_fields, color: colorScheme.onSurfaceVariant), - title: const Text('Filename Format'), - subtitle: Text(settings.filenameFormat), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showFormatEditor(context, ref, settings.filenameFormat), + 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 ? 'Music/SpotiFLAC' : settings.downloadDirectory, + onTap: () => _pickDirectory(ref), + ), + SettingsItem( + icon: Icons.create_new_folder_outlined, + title: 'Folder Organization', + subtitle: _getFolderOrganizationLabel(settings.folderOrganization), + onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization), + showDivider: false, + ), + ], ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: Icon(Icons.folder_outlined, color: colorScheme.onSurfaceVariant), - title: const Text('Download Directory'), - subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: const Icon(Icons.chevron_right), - onTap: () => _pickDirectory(ref), - ), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: Icon(Icons.create_new_folder_outlined, color: colorScheme.onSurfaceVariant), - title: const Text('Folder Organization'), - subtitle: Text(_getFolderOrganizationLabel(settings.folderOrganization)), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization), - ), - ])), + ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], @@ -150,13 +165,13 @@ class DownloadSettingsPage extends ConsumerWidget { String _getFolderOrganizationLabel(String value) { switch (value) { case 'artist': - return 'By Artist (Artist/Track.flac)'; + return 'By Artist'; case 'album': - return 'By Album (Album/Track.flac)'; + return 'By Album'; case 'artist_album': - return 'By Artist & Album (Artist/Album/Track.flac)'; + return 'By Artist & Album'; default: - return 'None (all in one folder)'; + return 'None'; } } @@ -215,17 +230,6 @@ class DownloadSettingsPage extends ConsumerWidget { } } -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)), - ); -} - class _ServiceSelector extends StatelessWidget { final String currentService; final ValueChanged onChanged; @@ -233,21 +237,15 @@ class _ServiceSelector extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Card( - elevation: 0, - color: colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(8), - 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')), - ]), - ), + 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')), + ]), ); } } @@ -262,9 +260,15 @@ class _ServiceChip extends StatelessWidget { @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 : Colors.transparent, + color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, @@ -288,20 +292,49 @@ class _ServiceChip extends StatelessWidget { class _QualityOption extends StatelessWidget { final String title; final String subtitle; - final String value; final bool isSelected; final VoidCallback onTap; - const _QualityOption({required this.title, required this.subtitle, required this.value, required this.isSelected, required this.onTap}); + final bool showDivider; + const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - title: Text(title), - subtitle: Text(subtitle), - trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: onTap, + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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)), + ], + ), + ), + isSelected + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 20, + endIndent: 20, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], ); } } diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index fb91085d..45f1d643 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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'; class OptionsSettingsPage extends ConsumerWidget { const OptionsSettingsPage({super.key}); @@ -55,78 +56,96 @@ class OptionsSettingsPage extends ConsumerWidget { ), // Download options section - SliverToBoxAdapter(child: _SectionHeader(title: 'Download')), - SliverList(delegate: SliverChildListDelegate([ - SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - secondary: Icon(Icons.sync, color: colorScheme.onSurfaceVariant), - title: const Text('Auto Fallback'), - subtitle: const Text('Try other services if download fails'), - value: settings.autoFallback, - onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.sync, + title: 'Auto Fallback', + subtitle: 'Try other services if download fails', + value: settings.autoFallback, + onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), + ), + SettingsSwitchItem( + icon: Icons.lyrics, + title: 'Embed Lyrics', + subtitle: 'Embed synced lyrics into FLAC files', + value: settings.embedLyrics, + onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v), + ), + SettingsSwitchItem( + icon: Icons.image, + title: 'Max Quality Cover', + subtitle: 'Download highest resolution cover art', + value: settings.maxQualityCover, + onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v), + showDivider: false, + ), + ], ), - SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - secondary: Icon(Icons.lyrics, color: colorScheme.onSurfaceVariant), - title: const Text('Embed Lyrics'), - subtitle: const Text('Embed synced lyrics into FLAC files'), - value: settings.embedLyrics, - onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedLyrics(v), - ), - SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - secondary: Icon(Icons.image, color: colorScheme.onSurfaceVariant), - title: const Text('Max Quality Cover'), - subtitle: const Text('Download highest resolution cover art'), - value: settings.maxQualityCover, - onChanged: (v) => ref.read(settingsProvider.notifier).setMaxQualityCover(v), - ), - ])), + ), // Performance section - SliverToBoxAdapter(child: _SectionHeader(title: 'Performance')), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Performance')), SliverToBoxAdapter( - child: _ConcurrentDownloadsSelector( - currentValue: settings.concurrentDownloads, - onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v), + child: SettingsGroup( + children: [ + _ConcurrentDownloadsItem( + currentValue: settings.concurrentDownloads, + onChanged: (v) => ref.read(settingsProvider.notifier).setConcurrentDownloads(v), + ), + ], ), ), // Lyrics section - SliverToBoxAdapter(child: _SectionHeader(title: 'Lyrics')), - SliverList(delegate: SliverChildListDelegate([ - SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - secondary: Icon(Icons.translate, color: colorScheme.onSurfaceVariant), - title: const Text('Convert Japanese to Romaji'), - subtitle: const Text('Auto-convert Hiragana/Katakana lyrics'), - value: settings.convertLyricsToRomaji, - onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Lyrics')), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.translate, + title: 'Convert Japanese to Romaji', + subtitle: 'Auto-convert Hiragana/Katakana lyrics', + value: settings.convertLyricsToRomaji, + onChanged: (v) => ref.read(settingsProvider.notifier).setConvertLyricsToRomaji(v), + showDivider: false, + ), + ], ), - ])), + ), // App section - SliverToBoxAdapter(child: _SectionHeader(title: 'App')), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'App')), SliverToBoxAdapter( - child: SwitchListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - secondary: Icon(Icons.system_update, color: colorScheme.onSurfaceVariant), - title: const Text('Check for Updates'), - subtitle: const Text('Notify when new version is available'), - value: settings.checkForUpdates, - onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v), + child: SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.system_update, + title: 'Check for Updates', + subtitle: 'Notify when new version is available', + value: settings.checkForUpdates, + onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v), + showDivider: false, + ), + ], ), ), // Data section - SliverToBoxAdapter(child: _SectionHeader(title: 'Data')), + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')), SliverToBoxAdapter( - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24), - leading: Icon(Icons.delete_forever, color: colorScheme.error), - title: const Text('Clear Download History'), - subtitle: const Text('Remove all downloaded tracks from history'), - onTap: () => _showClearHistoryDialog(context, ref, colorScheme), + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.delete_forever, + title: 'Clear Download History', + subtitle: 'Remove all downloaded tracks from history', + onTap: () => _showClearHistoryDialog(context, ref, colorScheme), + showDivider: false, + ), + ], ), ), @@ -163,35 +182,25 @@ class OptionsSettingsPage extends ConsumerWidget { } } -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text(title, style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)), - ); -} - -class _ConcurrentDownloadsSelector extends StatelessWidget { +class _ConcurrentDownloadsItem extends StatelessWidget { final int currentValue; final ValueChanged onChanged; - const _ConcurrentDownloadsSelector({required this.currentValue, required this.onChanged}); + const _ConcurrentDownloadsItem({required this.currentValue, required this.onChanged}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.all(20), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant), + Icon(Icons.download_for_offline, color: colorScheme.onSurfaceVariant, size: 24), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Concurrent Downloads'), + Text('Concurrent Downloads', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 2), Text(currentValue == 1 ? 'Sequential (1 at a time)' : '$currentValue parallel downloads', - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), ])), ]), const SizedBox(height: 16), @@ -223,9 +232,15 @@ class _ConcurrentChip extends StatelessWidget { @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 : colorScheme.surfaceContainerHighest, + color: isSelected ? colorScheme.primaryContainer : unselectedColor, borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index e2a3f662..8c737e85 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart import 'package:spotiflac_android/screens/settings/download_settings_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; class SettingsTab extends ConsumerWidget { const SettingsTab({super.key}); @@ -15,9 +16,9 @@ class SettingsTab extends ConsumerWidget { return CustomScrollView( slivers: [ - // Collapsing App Bar - Simplified for performance + // Collapsing App Bar SliverAppBar( - expandedHeight: 100, + expandedHeight: 130, collapsedHeight: kToolbarHeight, floating: false, pinned: true, @@ -25,12 +26,12 @@ class SettingsTab extends ConsumerWidget { surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, flexibleSpace: FlexibleSpaceBar( - expandedTitleScale: 1.4, + expandedTitleScale: 1.3, titlePadding: const EdgeInsets.only(left: 24, bottom: 16), title: Text( 'Settings', style: TextStyle( - fontSize: 20, + fontSize: 28, fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -38,35 +39,50 @@ class SettingsTab extends ConsumerWidget { ), ), - // Menu items - SliverList(delegate: SliverChildListDelegate([ - _SettingsMenuItem( - icon: Icons.palette_outlined, - title: 'Appearance', - subtitle: 'Theme, colors, display', - onTap: () => _navigateTo(context, const AppearanceSettingsPage()), + // First group: Appearance & Download + SliverToBoxAdapter( + child: SettingsGroup( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 4), + children: [ + SettingsItem( + icon: Icons.palette_outlined, + title: 'Appearance', + subtitle: 'Theme, colors, display', + onTap: () => _navigateTo(context, const AppearanceSettingsPage()), + ), + SettingsItem( + icon: Icons.download_outlined, + title: 'Download', + subtitle: 'Service, quality, filename format', + onTap: () => _navigateTo(context, const DownloadSettingsPage()), + ), + SettingsItem( + icon: Icons.tune_outlined, + title: 'Options', + subtitle: 'Fallback, lyrics, cover art, updates', + onTap: () => _navigateTo(context, const OptionsSettingsPage()), + showDivider: false, + ), + ], ), - _SettingsMenuItem( - icon: Icons.download_outlined, - title: 'Download', - subtitle: 'Service, quality, filename format', - onTap: () => _navigateTo(context, const DownloadSettingsPage()), + ), + + // Second group: About + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + SettingsItem( + icon: Icons.info_outline, + title: 'About', + subtitle: 'Version ${AppInfo.version}, credits, GitHub', + onTap: () => _navigateTo(context, const AboutPage()), + showDivider: false, + ), + ], ), - _SettingsMenuItem( - icon: Icons.tune_outlined, - title: 'Options', - subtitle: 'Fallback, lyrics, cover art, updates', - onTap: () => _navigateTo(context, const OptionsSettingsPage()), - ), - _SettingsMenuItem( - icon: Icons.info_outline, - title: 'About', - subtitle: 'Version ${AppInfo.version}, credits, GitHub', - onTap: () => _navigateTo(context, const AboutPage()), - ), - ])), + ), - // Fill remaining space to enable scroll + // Fill remaining space const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ], ); @@ -76,37 +92,3 @@ class SettingsTab extends ConsumerWidget { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } } - -class _SettingsMenuItem extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final VoidCallback onTap; - - const _SettingsMenuItem({required this.icon, required this.title, required this.subtitle, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: Row(children: [ - Container( - width: 44, height: 44, - decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), - child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 22), - ), - const SizedBox(width: 16), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), - const SizedBox(height: 2), - Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ])), - Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: 24), - ]), - ), - ); - } -} diff --git a/lib/screens/settings_tab.dart b/lib/screens/settings_tab.dart index d81eedac..9b9de98c 100644 --- a/lib/screens/settings_tab.dart +++ b/lib/screens/settings_tab.dart @@ -203,7 +203,7 @@ class _SettingsTabState extends ConsumerState with AutomaticKeepAli builder: (context) => AlertDialog( title: Row( children: [ - Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, __, ___) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), + Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)), const SizedBox(width: 12), Text(AppInfo.appName), ], diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index ab61236f..8134049a 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -191,7 +191,7 @@ class _TrackMetadataScreenState extends ConsumerState { ? CachedNetworkImage( imageUrl: item.coverUrl!, fit: BoxFit.cover, - placeholder: (_, __) => Container( + placeholder: (_, _) => Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, diff --git a/lib/services/apk_downloader.dart b/lib/services/apk_downloader.dart index a76058b2..3c12b5b7 100644 --- a/lib/services/apk_downloader.dart +++ b/lib/services/apk_downloader.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('ApkDownloader'); typedef ProgressCallback = void Function(int received, int total); @@ -17,7 +20,7 @@ class ApkDownloader { final response = await client.send(request); if (response.statusCode != 200) { - print('[ApkDownloader] Failed to download: ${response.statusCode}'); + _log.e('Failed to download: ${response.statusCode}'); return null; } @@ -26,7 +29,7 @@ class ApkDownloader { // Get download directory final dir = await getExternalStorageDirectory(); if (dir == null) { - print('[ApkDownloader] Could not get storage directory'); + _log.e('Could not get storage directory'); return null; } @@ -50,10 +53,10 @@ class ApkDownloader { await sink.close(); client.close(); - print('[ApkDownloader] Downloaded to: $filePath'); + _log.i('Downloaded to: $filePath'); return filePath; } catch (e) { - print('[ApkDownloader] Error: $e'); + _log.e('Error: $e'); return null; } } @@ -61,9 +64,9 @@ class ApkDownloader { static Future installApk(String filePath) async { try { final result = await OpenFilex.open(filePath); - print('[ApkDownloader] Open result: ${result.type} - ${result.message}'); + _log.i('Open result: ${result.type} - ${result.message}'); } catch (e) { - print('[ApkDownloader] Install error: $e'); + _log.e('Install error: $e'); } } } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index fcad30f8..be6016b5 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,6 +1,9 @@ import 'dart:io'; import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('FFmpeg'); /// FFmpeg service for audio conversion and remuxing class FFmpegService { @@ -27,7 +30,7 @@ class FFmpegService { // Log error for debugging final logs = await session.getLogs(); for (final log in logs) { - print('[FFmpeg] ${log.getMessage()}'); + _log.d(log.getMessage()); } return null; diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 79c7acbe..cf45c893 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -1,5 +1,8 @@ import 'dart:async'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('ShareIntent'); /// Service to handle incoming share intents from other apps (e.g., Spotify) class ShareIntentService { @@ -30,7 +33,7 @@ class ShareIntentService { // Listen to media sharing coming from outside the app while the app is in memory _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( _handleSharedMedia, - onError: (err) => print('[ShareIntent] Error: $err'), + onError: (err) => _log.e('Error: $err'), ); // Get the media sharing coming from outside the app while the app is closed @@ -49,7 +52,7 @@ class ShareIntentService { final url = _extractSpotifyUrl(textToCheck); if (url != null) { - print('[ShareIntent] Received Spotify URL: $url (initial: $isInitial)'); + _log.i('Received Spotify URL: $url (initial: $isInitial)'); if (isInitial) { // Store for later - listener might not be ready yet _pendingUrl = url; diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index bf61d0cf..68b2d4e3 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -2,12 +2,15 @@ import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('UpdateChecker'); class UpdateInfo { final String version; final String changelog; final String downloadUrl; - final String? apkDownloadUrl; // Direct APK download URL + final String? apkDownloadUrl; final DateTime publishedAt; const UpdateInfo({ @@ -22,20 +25,16 @@ class UpdateInfo { class UpdateChecker { static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; - /// Get device CPU architecture static Future _getDeviceArch() async { if (!Platform.isAndroid) return 'unknown'; try { - // Read CPU info from /proc/cpuinfo final cpuInfo = await File('/proc/cpuinfo').readAsString(); - // Check for 64-bit indicators if (cpuInfo.contains('AArch64') || cpuInfo.contains('aarch64')) { return 'arm64'; } - // Check architecture from uname final result = await Process.run('uname', ['-m']); final arch = result.stdout.toString().trim().toLowerCase(); @@ -49,14 +48,13 @@ class UpdateChecker { return 'x86'; } - return 'arm64'; // Default to arm64 for modern devices + return 'arm64'; } catch (e) { - print('[UpdateChecker] Error detecting arch: $e'); - return 'arm64'; // Default fallback + _log.e('Error detecting arch: $e'); + return 'arm64'; } } - /// Check for updates from GitHub releases static Future checkForUpdate() async { try { final response = await http.get( @@ -65,7 +63,7 @@ class UpdateChecker { ).timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { - print('[UpdateChecker] GitHub API returned ${response.statusCode}'); + _log.w('GitHub API returned ${response.statusCode}'); return null; } @@ -74,18 +72,16 @@ class UpdateChecker { final latestVersion = tagName.replaceFirst('v', ''); if (!_isNewerVersion(latestVersion, AppInfo.version)) { - print('[UpdateChecker] No update available (current: ${AppInfo.version}, latest: $latestVersion)'); + _log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)'); return null; } - // Get changelog from release body final body = data['body'] as String? ?? 'No changelog available'; final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now(); - // Find APK download URL from assets based on device architecture final deviceArch = await _getDeviceArch(); - print('[UpdateChecker] Device architecture: $deviceArch'); + _log.d('Device architecture: $deviceArch'); String? arm64Url; String? arm32Url; @@ -106,7 +102,6 @@ class UpdateChecker { } } - // Select APK based on device architecture String? apkUrl; if (deviceArch == 'arm64') { apkUrl = arm64Url ?? universalUrl ?? arm32Url; @@ -116,7 +111,7 @@ class UpdateChecker { apkUrl = universalUrl ?? arm64Url ?? arm32Url; } - print('[UpdateChecker] Update available: $latestVersion, APK URL: $apkUrl'); + _log.i('Update available: $latestVersion, APK URL: $apkUrl'); return UpdateInfo( version: latestVersion, @@ -126,18 +121,19 @@ class UpdateChecker { publishedAt: publishedAt, ); } catch (e) { - print('[UpdateChecker] Error checking for updates: $e'); + _log.e('Error checking for updates: $e'); return null; } } - /// Compare version strings (e.g., "1.1.1" vs "1.1.0") static bool _isNewerVersion(String latest, String current) { try { - final latestParts = latest.split('.').map(int.parse).toList(); - final currentParts = current.split('.').map(int.parse).toList(); + final latestBase = latest.split('-').first; + final currentBase = current.split('-').first; + + final latestParts = latestBase.split('.').map(int.parse).toList(); + final currentParts = currentBase.split('.').map(int.parse).toList(); - // Pad with zeros if needed while (latestParts.length < 3) { latestParts.add(0); } @@ -149,8 +145,15 @@ class UpdateChecker { if (latestParts[i] > currentParts[i]) return true; if (latestParts[i] < currentParts[i]) return false; } - return false; // Same version + + final latestHasSuffix = latest.contains('-'); + final currentHasSuffix = current.contains('-'); + + if (!latestHasSuffix && currentHasSuffix) return true; + + return false; } catch (e) { + _log.e('Error comparing versions: $e'); return false; } } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart new file mode 100644 index 00000000..31bc2583 --- /dev/null +++ b/lib/utils/logger.dart @@ -0,0 +1,28 @@ +import 'package:logger/logger.dart'; + +/// Global logger instance for the app +/// Uses pretty printer in debug mode for readable output +final log = Logger( + printer: PrettyPrinter( + methodCount: 0, + errorMethodCount: 5, + lineLength: 80, + colors: true, + printEmojis: true, + dateTimeFormat: DateTimeFormat.none, + ), + level: Level.debug, +); + +/// Logger with class/tag prefix for better traceability +class AppLogger { + final String _tag; + + AppLogger(this._tag); + + void d(String message) => log.d('[$_tag] $message'); + void i(String message) => log.i('[$_tag] $message'); + void w(String message) => log.w('[$_tag] $message'); + void e(String message, [Object? error, StackTrace? stackTrace]) => + log.e('[$_tag] $message', error: error, stackTrace: stackTrace); +} diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart new file mode 100644 index 00000000..16cf587b --- /dev/null +++ b/lib/widgets/settings_group.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; + +/// A grouped settings card that connects items together like Android Settings +/// Items are connected with no gap between them, only separated when changing groups +class SettingsGroup extends StatelessWidget { + final List children; + final EdgeInsetsGeometry? margin; + + const SettingsGroup({ + super.key, + required this.children, + this.margin, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + // Use a more contrasting color for cards + // In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface + // So we add a slight white overlay to make it more visible + final cardColor = isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + : colorScheme.surfaceContainerHighest; + + return Container( + margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } +} + +/// A single settings item that can be used inside SettingsGroup +class SettingsItem extends StatelessWidget { + final IconData? icon; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final bool showDivider; + + const SettingsItem({ + super.key, + this.icon, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ] else if (onTap != null) ...[ + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), + ], + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: icon != null ? 56 : 20, + endIndent: 20, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +/// A switch settings item for SettingsGroup +class SettingsSwitchItem extends StatelessWidget { + final IconData? icon; + final String title; + final String? subtitle; + final bool value; + final ValueChanged? onChanged; + final bool showDivider; + + const SettingsSwitchItem({ + super.key, + this.icon, + required this.title, + this.subtitle, + required this.value, + this.onChanged, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onChanged != null ? () => onChanged!(!value) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Switch( + value: value, + onChanged: onChanged, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: icon != null ? 56 : 20, + endIndent: 20, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +/// Section header for settings groups +class SettingsSectionHeader extends StatelessWidget { + final String title; + + const SettingsSectionHeader({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(32, 24, 32, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f3e18361..8747d330 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -576,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logger: + dependency: "direct main" + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b85990fe..94dd3d9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 1.5.5+22 +version: 1.5.6+23 environment: sdk: ^3.10.0 @@ -48,6 +48,7 @@ dependencies: device_info_plus: ^12.3.0 share_plus: ^10.1.4 receive_sharing_intent: ^1.8.1 + logger: ^2.5.0 # FFmpeg for audio conversion (audio-only version - much smaller) ffmpeg_kit_flutter_new_audio: ^2.0.0