v1.5.6: UI improvements, logger migration, and bug fixes

- Fix update checker for versions with suffix (hotfix/beta/rc)
- Add collapsing header to Search tab for consistent UI
- Redesign Settings with Android-style grouped cards
- Increase app bar title size (28px) and height (130px)
- Replace all print() with structured logging (logger package)
- Fix lint warnings (curly braces, unnecessary underscores)
This commit is contained in:
zarzet
2026-01-02 15:16:50 +07:00
parent 62805720da
commit 973c2e3b41
22 changed files with 792 additions and 444 deletions
+21
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+2 -2
View File
@@ -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';
+49 -46
View File
@@ -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<DownloadHistoryState> {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).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<DownloadHistoryState> {
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<DownloadQueueState> {
// 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<DownloadQueueState> {
// 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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
// 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<DownloadQueueState> {
} 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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
// 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<DownloadQueueState> {
);
}
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<DownloadQueueState> {
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<DownloadQueueState> {
);
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
});
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<DownloadQueueState> {
/// Download a single item (used by both sequential and parallel processing)
Future<void> _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<DownloadQueueState> {
Map<String, dynamic> 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<DownloadQueueState> {
_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<DownloadQueueState> {
}
} 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<DownloadQueueState> {
// 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,
+83 -56
View File
@@ -74,13 +74,11 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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,
),
+5 -2
View File
@@ -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<MainShell> {
// 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);
});
}
+3 -3
View File
@@ -78,7 +78,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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,
),
+1 -1
View File
@@ -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),
@@ -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<ThemeMode> 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,
+117 -84
View File
@@ -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<String> 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),
),
],
);
}
}
+88 -73
View File
@@ -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<int> 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,
+46 -64
View File
@@ -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),
]),
),
);
}
}
+1 -1
View File
@@ -203,7 +203,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> 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),
],
+1 -1
View File
@@ -191,7 +191,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
placeholder: (_, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
+9 -6
View File
@@ -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<void> 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');
}
}
}
+4 -1
View File
@@ -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;
+5 -2
View File
@@ -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;
+25 -22
View File
@@ -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<String> _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<UpdateInfo?> 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;
}
}
+28
View File
@@ -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);
}
+219
View File
@@ -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<Widget> 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<bool>? 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,
),
),
);
}
}
+8
View File
@@ -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:
+2 -1
View File
@@ -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